Compare commits

...

31 Commits

Author SHA1 Message Date
jkunz 01df877480 v3.5.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-15 05:38:50 +00:00
jkunz f138495edf feat(tools): add reusable execution contexts for shell, filesystem, and browser tools 2026-05-15 05:38:35 +00:00
jkunz f183bf19ac v3.4.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 22:44:10 +00:00
jkunz 6fb2b3a61f feat(agent): add streamed reasoning summary callbacks to runAgent 2026-05-14 22:44:08 +00:00
jkunz ca56f4c4e8 v3.3.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 16:50:16 +00:00
jkunz 5ceeddd8bb feat(deps): upgrade @push.rocks/smartai to ^4.0.0 2026-05-14 16:50:08 +00:00
jkunz d7edb981e7 v3.2.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 11:34:11 +00:00
jkunz e6346be884 feat(agent): add prompt caching options and cache token usage reporting 2026-05-14 11:34:04 +00:00
jkunz 7be67543bf v3.1.1
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-11 11:11:43 +00:00
jkunz 28b9b215f3 fix(smartconfig): update release configuration to schema version 2 with npm target settings 2026-05-11 11:11:40 +00:00
jkunz e8e463b567 v3.1.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-07 10:26:45 +00:00
jkunz b08cb3689e feat(agent): add provider options passthrough, tool call records, and completion validation retries 2026-05-07 10:26:45 +00:00
jkunz 0dde716109 v3.0.3
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-30 11:27:08 +00:00
jkunz 6f5e49e5ef fix(build): tighten TypeScript configuration and update dependencies for zod v4 compatibility 2026-04-30 11:27:08 +00:00
jkunz e8fcdd05af v3.0.2
Default (tags) / security (push) Successful in 41s
Default (tags) / test (push) Failing after 4m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-06 22:48:09 +00:00
jkunz 91865e9f57 fix(agent): use output parameter when invoking onToolResult instead of toolCall.result 2026-03-06 22:48:09 +00:00
jkunz 2947842499 v3.0.1
Default (tags) / security (push) Successful in 44s
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:41:30 +00:00
jkunz c503690d52 fix(readme): adjust ASCII art in README to fix box widths and spacing in agent diagram 2026-03-06 11:41:30 +00:00
jkunz 38556c8b12 v3.0.0
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
jkunz 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
jkunz 903de44644 v1.8.0
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
jkunz 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
jkunz 5ca0c80ea9 v1.7.0
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
jkunz 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
jkunz c1b269f301 v1.6.2
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
jkunz 7cb970f9e2 fix(release): bump version to 1.6.2 2026-01-20 03:56:44 +00:00
jkunz 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
jkunz 4a8789019a v1.6.1
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
jkunz 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
jkunz 121e216eea v1.6.0
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
jkunz 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
39 changed files with 5392 additions and 8283 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"@git.zone/cli": {
"projectType": "npm",
"module": {
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartagent",
"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"
},
"release": {
"targets": {
"npm": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
}
},
"schemaVersion": 2
},
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []
}
}
+131 -1
View File
@@ -1,5 +1,135 @@
# Changelog
## Pending
## 2026-05-15 - 3.5.0
### Features
- add reusable execution contexts for shell, filesystem, and browser tools (tools)
- introduces shared tool execution context interfaces plus a local Node.js context implementation
- adds createShellTools, createFilesystemTools, and createBrowserTools for host-provided transports with permission hooks
- re-exports the new tool factories and context types from the main package entrypoint while keeping compatibility wrappers
## 2026-05-14 - 3.4.0
### Features
- add streamed reasoning summary callbacks to runAgent (agent)
- Introduces onReasoningStart, onReasoningDelta, and onReasoningEnd callbacks in the agent options interface
- Handles reasoning-start, reasoning-delta, and reasoning-end stream chunks while accumulating reasoning text by id
- Ensures incomplete reasoning streams are finalized after the response completes
- Adds tests for reasoning summary streaming and updates the README API documentation
## 2026-05-14 - 3.3.0
### Features
- upgrade @push.rocks/smartai to ^4.0.0 (deps)
- Updates the core smartai dependency from ^2.3.0 to ^4.0.0.
- Refreshes README hints to document the new smartai version.
## 2026-05-14 - 3.2.0
### Features
- add prompt caching options and cache token usage reporting (agent)
- adds sessionId and cache run options to configure provider-specific prompt caching defaults
- applies OpenAI cache provider options and Anthropic cache breakpoints automatically, with support to disable defaults
- extends usage reporting to include cacheReadTokens and cacheWriteTokens
- exports cache-related types and helpers and updates tests and README to cover the new behavior
## 2026-05-11 - 3.1.1
### Fixes
- update release configuration to schema version 2 with npm target settings (smartconfig)
- migrates release settings from a flat registries/accessLevel structure to a nested targets.npm configuration
- adds schemaVersion 2 to align the smartconfig format with the updated release schema
## 2026-05-07 - 3.1.0 - feat(agent)
add provider options passthrough, tool call records, and completion validation retries
- forward provider-specific options to the underlying streamText call
- return structured tool call records with inputs, outputs, and errors in agent results
- support validateCompletion with reprompting and configurable validation retry limits
- export ProviderOptions and tool call record types for consumers
- update tests and documentation for the new agent run options and result fields
## 2026-04-30 - 3.0.3 - fix(build)
tighten TypeScript configuration and update dependencies for zod v4 compatibility
- enable stricter TypeScript checks with noImplicitAny and explicit node types
- update HTTP tool schemas to use explicit z.record key and value types for newer zod versions
- adjust test typing for calculator operations and refresh build-related dependencies and package metadata
## 2026-03-06 - 3.0.2 - fix(agent)
use output parameter when invoking onToolResult instead of toolCall.result
- Replace (toolCall as any).result with the explicit output parameter when calling options.onToolResult.
- Prevents undefined/misread results by aligning the callback with the tool runner's output signature.
## 2026-03-06 - 3.0.1 - fix(readme)
adjust ASCII art in README to fix box widths and spacing in agent diagram
- Updated readme.md diagram: expanded 'Messages' box width and realigned 'streamText' and 'Tools' columns
- Documentation-only change; no code or behavior affected
## 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
@@ -110,4 +240,4 @@ Bump version to 1.0.2 (patch release)
Initial commit: project scaffold and first release.
- Repository initialized with initial project structure and baseline files.
- Version set to 1.0.1.
- Version set to 1.0.1.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
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"
+36 -24
View File
@@ -1,35 +1,50 @@
{
"name": "@push.rocks/smartagent",
"version": "1.5.4",
"version": "3.5.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)",
"buildDocs": "(tsdoc)"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.3",
"@types/node": "^25.0.2"
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@push.rocks/qenv": "^6.1.3",
"@types/json-schema": "^7.0.15",
"@types/lodash.clonedeep": "^4.5.9",
"@types/node": "^25.6.0"
},
"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": "^4.0.0",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartshell": "^3.3.0",
"minimatch": "^10.1.1"
"@push.rocks/smartshell": "^3.3.8",
"ai": "^6.0.182",
"zod": "^4.4.1"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"packageManager": "pnpm@10.28.2",
"repository": {
"type": "git",
"url": "https://code.foss.global/push.rocks/smartagent.git"
@@ -40,17 +55,14 @@
"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",
".smartconfig.json",
"license",
"npmextra.json",
"readme.md"
],
"pnpm": {
"overrides": {}
}
]
}
+2563 -3405
View File
File diff suppressed because it is too large Load Diff
+40 -29
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` ^4.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`
+440 -547
View File
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
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[] = [];
type TCalculatorOperation = 'add' | 'subtract' | 'multiply' | 'divide';
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: TCalculatorOperation; 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();
+502 -121
View File
@@ -1,150 +1,531 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
import * as smartagent from '../ts/index.js';
import { createBrowserTools, createFilesystemTools, createShellTools, 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');
const createUsage = (inputTokens: number, outputTokens: number) => ({
inputTokens: {
total: inputTokens,
noCache: inputTokens,
cacheRead: 0,
cacheWrite: 0,
},
outputTokens: {
total: outputTokens,
text: outputTokens,
reasoning: 0,
},
});
tap.test('should export DriverAgent class', async () => {
expect(smartagent.DriverAgent).toBeTypeOf('function');
const createTextStreamResult = (text: string) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: text },
{ type: 'text-end', id: 'text-1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: createUsage(1, 1),
},
] as any[]),
});
tap.test('should export GuardianAgent class', async () => {
expect(smartagent.GuardianAgent).toBeTypeOf('function');
const createReasoningStreamResult = (reasoning: string, text: string) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
{ type: 'reasoning-start', id: 'reasoning-1' },
{ type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(0, 7) },
{ type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(7) },
{ type: 'reasoning-end', id: 'reasoning-1' },
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: text },
{ type: 'text-end', id: 'text-1' },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: createUsage(2, 2),
},
] as any[]),
});
tap.test('should export BaseToolWrapper class', async () => {
expect(smartagent.BaseToolWrapper).toBeTypeOf('function');
const createToolCallStreamResult = (toolName: string, input: unknown) => ({
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
{
type: 'tool-call',
toolCallId: 'tool-call-1',
toolName,
input: JSON.stringify(input),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool-calls' },
usage: createUsage(2, 1),
},
] as any[]),
});
// Test standard tools exports
tap.test('should export FilesystemTool class', async () => {
expect(smartagent.FilesystemTool).toBeTypeOf('function');
// ============================================================
// Core exports
// ============================================================
tap.test('should export runAgent function', async () => {
expect(smartagent.runAgent).toBeTypeOf('function');
});
tap.test('should export HttpTool class', async () => {
expect(smartagent.HttpTool).toBeTypeOf('function');
tap.test('should export ToolRegistry class', async () => {
expect(smartagent.ToolRegistry).toBeTypeOf('function');
});
tap.test('should export ShellTool class', async () => {
expect(smartagent.ShellTool).toBeTypeOf('function');
tap.test('should export ContextOverflowError class', async () => {
expect(smartagent.ContextOverflowError).toBeTypeOf('function');
});
tap.test('should export BrowserTool class', async () => {
expect(smartagent.BrowserTool).toBeTypeOf('function');
tap.test('should export truncateOutput function', async () => {
expect(smartagent.truncateOutput).toBeTypeOf('function');
});
tap.test('should export DenoTool class', async () => {
expect(smartagent.DenoTool).toBeTypeOf('function');
tap.test('should re-export tool helper', async () => {
expect(smartagent.tool).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 re-export z (zod)', async () => {
expect(smartagent.z).toBeTruthy();
});
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 re-export stepCountIs', async () => {
expect(smartagent.stepCountIs).toBeTypeOf('function');
});
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('runAgent should forward providerOptions to streamText', async () => {
const model = new MockLanguageModelV3({
doStream: async () => createTextStreamResult('ok') as any,
});
expect(summary).toBeTypeOf('string');
expect(summary).toInclude('permissions');
expect(summary).toInclude('net');
const providerOptions = {
openai: {
reasoningEffort: 'xhigh',
},
} as const;
const result = await smartagent.runAgent({
model,
prompt: 'hello',
providerOptions,
});
expect(result.text).toEqual('ok');
expect((model.doStreamCalls[0].providerOptions as any).openai.reasoningEffort).toEqual('xhigh');
});
tap.test('runAgent should add OpenAI cache defaults when sessionId is provided', async () => {
const model = new MockLanguageModelV3({
provider: 'openai',
modelId: 'gpt-5',
doStream: async () => createTextStreamResult('ok') as any,
});
const result = await smartagent.runAgent({
model,
prompt: 'hello',
sessionId: 'session-123',
providerOptions: {
openai: {
reasoningEffort: 'high',
},
} as any,
});
const openaiOptions = (model.doStreamCalls[0].providerOptions as any).openai;
expect(result.text).toEqual('ok');
expect(openaiOptions.store).toEqual(false);
expect(openaiOptions.promptCacheKey).toEqual('session-123');
expect(openaiOptions.promptCacheRetention).toEqual('in_memory');
expect(openaiOptions.reasoningEffort).toEqual('high');
});
tap.test('runAgent should stream reasoning summary callbacks', async () => {
const reasoningEvents: string[] = [];
const tokenDeltas: string[] = [];
const model = new MockLanguageModelV3({
doStream: async () => createReasoningStreamResult('thinking through it', 'done') as any,
});
const result = await smartagent.runAgent({
model,
prompt: 'hello',
onToken: (delta) => tokenDeltas.push(delta),
onReasoningStart: (id) => reasoningEvents.push('start:' + id),
onReasoningDelta: (id, delta) => reasoningEvents.push('delta:' + id + ':' + delta),
onReasoningEnd: (id, text) => reasoningEvents.push('end:' + id + ':' + text),
});
expect(result.text).toEqual('done');
expect(tokenDeltas.join('')).toEqual('done');
expect(reasoningEvents).toEqual([
'start:reasoning-1',
'delta:reasoning-1:thinkin',
'delta:reasoning-1:g through it',
'end:reasoning-1:thinking through it',
]);
});
tap.test('runAgent should mark Anthropic prompt cache breakpoints by default', async () => {
const model = new MockLanguageModelV3({
provider: 'anthropic',
modelId: 'claude-sonnet-4-5-20250929',
doStream: async () => createTextStreamResult('ok') as any,
});
const result = await smartagent.runAgent({
model,
system: 'stable system prompt',
prompt: 'hello',
});
const prompt = model.doStreamCalls[0].prompt as any[];
const systemMessage = prompt.find((message) => message.role === 'system');
const userMessage = prompt.find((message) => message.role === 'user');
expect(result.text).toEqual('ok');
expect(systemMessage.providerOptions?.anthropic?.cacheControl?.type).toEqual('ephemeral');
expect(userMessage.providerOptions?.anthropic?.cacheControl?.type).toEqual('ephemeral');
});
tap.test('runAgent should allow cache defaults to be disabled', async () => {
const model = new MockLanguageModelV3({
provider: 'openai',
modelId: 'gpt-5',
doStream: async () => createTextStreamResult('ok') as any,
});
await smartagent.runAgent({
model,
prompt: 'hello',
sessionId: 'session-123',
cache: false,
});
expect(model.doStreamCalls[0].providerOptions).toBeUndefined();
});
tap.test('runAgent should return final tool call records', async () => {
let streamCallCount = 0;
const callbackToolCalls: Array<{ name: string; input: unknown }> = [];
const callbackToolResults: Array<{ name: string; result: unknown }> = [];
const model = new MockLanguageModelV3({
doStream: async () => {
streamCallCount++;
return streamCallCount === 1
? createToolCallStreamResult('echo', { text: 'hello' }) as any
: createTextStreamResult('saved') as any;
},
});
const result = await smartagent.runAgent({
model,
prompt: 'echo hello',
tools: {
echo: smartagent.tool({
description: 'Echo text',
inputSchema: smartagent.z.object({ text: smartagent.z.string() }),
execute: async ({ text }: { text: string }) => `saved:${text}`,
}),
},
maxSteps: 5,
onToolCall: (name, input) => callbackToolCalls.push({ name, input }),
onToolResult: (name, result) => callbackToolResults.push({ name, result }),
});
const echoCall = result.toolCalls.find((toolCall) => toolCall.toolName === 'echo');
expect(result.text).toEqual('saved');
expect(echoCall).toBeTruthy();
expect(echoCall!.input).toEqual({ text: 'hello' });
expect(echoCall!.output).toEqual('saved:hello');
expect(callbackToolCalls[0]).toEqual({ name: 'echo', input: { text: 'hello' } });
expect(callbackToolResults[0]).toEqual({ name: 'echo', result: 'saved:hello' });
});
tap.test('runAgent should reprompt when validateCompletion returns a string', async () => {
let streamCallCount = 0;
let validationCallCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
streamCallCount++;
return createTextStreamResult(streamCallCount === 1 ? 'incomplete' : 'complete') as any;
},
});
const result = await smartagent.runAgent({
model,
prompt: 'process document',
maxValidationRetries: 1,
validateCompletion: (runResult) => {
validationCallCount++;
return runResult.text === 'complete' ? undefined : 'Call a save tool before finalizing.';
},
});
expect(result.text).toEqual('complete');
expect(validationCallCount).toEqual(2);
expect(model.doStreamCalls.length).toEqual(2);
expect(JSON.stringify(model.doStreamCalls[1].prompt)).toInclude('Call a save tool before finalizing.');
});
tap.test('runAgent should reject when validation retries are exhausted', async () => {
let threw = false;
const model = new MockLanguageModelV3({
doStream: async () => createTextStreamResult('incomplete') as any,
});
try {
await smartagent.runAgent({
model,
prompt: 'process document',
validateCompletion: () => 'Missing required save tool call.',
});
} catch (error) {
threw = true;
expect((error as Error).message).toInclude('Missing required save tool call.');
}
expect(threw).toBeTrue();
});
// ============================================================
// ToolRegistry
// ============================================================
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,
});
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('createShellTools should execute through supplied context', async () => {
const permissions: unknown[] = [];
const calls: unknown[] = [];
const tools = createShellTools({
cwd: '/workspace',
requestPermission: async (request) => {
permissions.push(request);
},
shell: {
run: async (command, options) => {
calls.push({ command, options });
return { exitCode: 0, stdout: 'context-output', stderr: '' };
},
},
});
const result = await (tools.run_command as any).execute({ command: 'echo test', timeoutMs: 1234 });
expect(result).toEqual('context-output');
expect(calls[0]).toEqual({ command: 'echo test', options: { cwd: '/workspace', timeoutMs: 1234, abortSignal: undefined } });
expect(JSON.stringify(permissions[0])).toInclude('echo test');
});
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('createFilesystemTools should execute through supplied context', async () => {
const permissions: unknown[] = [];
const files = new Map<string, string>([['hello.txt', 'line1\nline2\nline3']]);
const tools = createFilesystemTools({
requestPermission: async (request) => {
permissions.push(request);
},
fs: {
readFile: async (filePath, options) => {
const content = files.get(filePath) ?? '';
if (options?.startLine || options?.endLine) {
const lines = content.split('\n');
return lines.slice((options.startLine ?? 1) - 1, options.endLine ?? lines.length).join('\n');
}
return content;
},
writeFile: async (filePath, content) => {
files.set(filePath, content);
return 'written';
},
listDirectory: async () => [...files.keys()],
},
}, { includeDelete: false });
const readResult = await (tools.read_file as any).execute({ path: 'hello.txt', startLine: 2, endLine: 2 });
const writeResult = await (tools.write_file as any).execute({ path: 'created.txt', content: 'created' });
const listResult = await (tools.list_directory as any).execute({ path: '.' });
expect(readResult).toEqual('line2');
expect(writeResult).toEqual('written');
expect(listResult).toInclude('created.txt');
expect(Object.keys(tools)).not.toContain('delete_file');
expect(JSON.stringify(permissions[0])).toInclude('created.txt');
});
tap.test('createBrowserTools should execute through supplied browser context', async () => {
const permissions: unknown[] = [];
const calls: unknown[] = [];
const tools = createBrowserTools({
requestPermission: async (request) => {
permissions.push(request);
},
browser: {
execute: async (input, options) => {
calls.push({ input, options });
return `browser:${input.action}:${input.url ?? ''}`;
},
},
});
const result = await (tools.browser as any).execute({ action: 'navigate', url: 'https://example.com', timeoutMs: 500 });
expect(result).toEqual('browser:navigate:https://example.com');
expect(JSON.stringify(calls[0])).toInclude('navigate');
expect(JSON.stringify(permissions[0])).toInclude('https://example.com');
});
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();
+2 -2
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartagent',
version: '1.5.4',
description: 'an agentic framework built on top of @push.rocks/smartai'
version: '3.5.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.'
}
+47 -30
View File
@@ -1,31 +1,48 @@
import * as plugins from './plugins.js';
// Export the dual-agent orchestrator (main entry point)
export { DualAgentOrchestrator } from './smartagent.classes.dualagent.js';
// Export individual agents
export { DriverAgent } from './smartagent.classes.driveragent.js';
export { GuardianAgent } from './smartagent.classes.guardianagent.js';
// Export 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 { 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 {
IAgentCacheOptions,
IAgentRunOptions,
IAgentRunResult,
IAgentToolCallRecord,
ProviderOptions,
TAgentCacheRetention,
TAgentCacheSetting,
} from './smartagent.interfaces.js';
export {
type ISmartAiOptions,
type TProvider,
type ChatMessage,
type ChatOptions,
type ChatResponse,
} from '@push.rocks/smartai';
createBrowserTools,
createFilesystemTools,
createLocalToolExecutionContext,
createShellTools,
filesystemTool,
formatShellResult,
formatToolOutput,
shellTool,
} from '../ts_tools/index.js';
export type {
IBrowserToolInput,
ICreateBrowserToolsOptions,
ICreateFilesystemToolsOptions,
ICreateShellToolsOptions,
IFilesystemToolOptions,
ILocalToolExecutionContextOptions,
IShellToolOptions,
IToolBrowserContext,
IToolExecutionContext,
IToolFilesystemContext,
IToolFilesystemListOptions,
IToolFilesystemReadOptions,
IToolPermissionRequest,
IToolRunOptions,
IToolShellContext,
IToolShellResult,
TBrowserToolAction,
} from '../ts_tools/index.js';
// 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';
+35 -16
View File
@@ -3,24 +3,43 @@ import * as path from 'path';
export { path };
// third party
import { minimatch } from 'minimatch';
// ai-sdk core
import { streamText, generateText, stepCountIs, wrapLanguageModel } from 'ai';
export { minimatch };
export { streamText, generateText, stepCountIs, wrapLanguageModel };
// @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';
// @push.rocks/smartai
import {
applySmartAiCacheProviderOptions,
createSmartAiCachingMiddleware,
jsonSchema,
resolveSmartAiCacheProvider,
tool,
} from '@push.rocks/smartai';
export {
smartai,
smartdeno,
smartfs,
smartrequest,
smartbrowser,
smartshell,
applySmartAiCacheProviderOptions,
createSmartAiCachingMiddleware,
resolveSmartAiCacheProvider,
tool,
jsonSchema,
};
export type {
ISmartAiCacheOptions,
LanguageModelV3,
TSmartAiCacheRetention,
TSmartAiCacheSetting,
TSmartAiProviderOptions as ProviderOptions,
} from '@push.rocks/smartai';
// zod
import { z } from 'zod';
export { z };
+377
View File
@@ -0,0 +1,377 @@
// 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, IAgentToolCallRecord } 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;
}
function parseToolInput(input: unknown): unknown {
if (typeof input !== 'string') return input;
try {
return JSON.parse(input);
} catch {
return input;
}
}
function errorToString(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
function tokenTotal(tokenUsage: unknown): number {
if (typeof tokenUsage === 'number') return tokenUsage;
if (tokenUsage && typeof tokenUsage === 'object' && typeof (tokenUsage as any).total === 'number') {
return (tokenUsage as any).total;
}
return 0;
}
function tokenCacheRead(tokenUsage: unknown): number {
if (tokenUsage && typeof tokenUsage === 'object' && typeof (tokenUsage as any).cacheRead === 'number') {
return (tokenUsage as any).cacheRead;
}
return 0;
}
function tokenCacheWrite(tokenUsage: unknown): number {
if (tokenUsage && typeof tokenUsage === 'object' && typeof (tokenUsage as any).cacheWrite === 'number') {
return (tokenUsage as any).cacheWrite;
}
return 0;
}
function recordToolCall(
toolCalls: IAgentToolCallRecord[],
toolCallIndexes: Map<string, number>,
toolCall: unknown,
update: { output?: unknown; error?: unknown } = {},
): void {
const call = toolCall as any;
const toolCallId = call?.toolCallId;
const nextRecord: IAgentToolCallRecord = {
toolName: String(call?.toolName ?? ''),
input: parseToolInput(call?.input ?? call?.args),
};
const hasOutput = Object.prototype.hasOwnProperty.call(update, 'output');
const hasError = Object.prototype.hasOwnProperty.call(update, 'error');
if (hasOutput) nextRecord.output = update.output;
if (hasError && update.error !== undefined) nextRecord.error = errorToString(update.error);
const existingIndex = typeof toolCallId === 'string' ? toolCallIndexes.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingRecord = toolCalls[existingIndex];
existingRecord.toolName = nextRecord.toolName || existingRecord.toolName;
if (nextRecord.input !== undefined) existingRecord.input = nextRecord.input;
if (hasOutput) existingRecord.output = nextRecord.output;
if (nextRecord.error !== undefined) existingRecord.error = nextRecord.error;
return;
}
toolCalls.push(nextRecord);
if (typeof toolCallId === 'string') {
toolCallIndexes.set(toolCallId, toolCalls.length - 1);
}
}
export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResult> {
let stepCount = 0;
let attempt = 0;
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
let validationRetries = 0;
const toolCalls: IAgentToolCallRecord[] = [];
const toolCallIndexes = new Map<string, number>();
const reasoningTextById = new Map<string, string>();
const tools = options.tools ?? {};
const cache = options.cache ?? 'auto';
const configuredCacheProvider = typeof cache === 'object' ? cache.provider : undefined;
const messageCacheProvider = cache === false
? undefined
: configuredCacheProvider ?? plugins.resolveSmartAiCacheProvider(options.model.provider, options.model.modelId);
const model = messageCacheProvider
? plugins.wrapLanguageModel({
model: options.model,
middleware: plugins.createSmartAiCachingMiddleware({
...(typeof cache === 'object' ? cache : {}),
provider: messageCacheProvider,
}),
}) as unknown as plugins.LanguageModelV3
: options.model;
const providerOptions = plugins.applySmartAiCacheProviderOptions({
provider: options.model.provider,
modelId: options.model.modelId,
providerOptions: options.providerOptions,
cache,
sessionId: options.sessionId,
});
// 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,
system: options.system,
messages,
tools: allTools,
providerOptions,
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 }) => {
const chunkType = String((chunk as any).type || '');
if (chunkType === 'text-delta' && options.onToken) {
options.onToken((chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? '');
return;
}
if (chunkType === 'reasoning-start') {
const id = (chunk as any).id || 'reasoning';
reasoningTextById.set(id, '');
options.onReasoningStart?.(id, (chunk as any).providerMetadata);
return;
}
if (chunkType === 'reasoning-delta') {
const id = (chunk as any).id || 'reasoning';
const delta = (chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? '';
if (!reasoningTextById.has(id)) {
reasoningTextById.set(id, '');
options.onReasoningStart?.(id, (chunk as any).providerMetadata);
}
reasoningTextById.set(id, (reasoningTextById.get(id) ?? '') + delta);
options.onReasoningDelta?.(id, delta, (chunk as any).providerMetadata);
return;
}
if (chunkType === 'reasoning-end') {
const id = (chunk as any).id || 'reasoning';
const text = reasoningTextById.get(id) ?? '';
reasoningTextById.delete(id);
options.onReasoningEnd?.(id, text, (chunk as any).providerMetadata);
}
},
experimental_onToolCallStart: options.onToolCall
? ({ toolCall }) => {
const input = parseToolInput((toolCall as any).input ?? (toolCall as any).args);
recordToolCall(toolCalls, toolCallIndexes, toolCall);
options.onToolCall!(toolCall.toolName, input);
}
: ({ toolCall }) => {
recordToolCall(toolCalls, toolCallIndexes, toolCall);
},
experimental_onToolCallFinish: options.onToolResult
? (event) => {
recordToolCall(
toolCalls,
toolCallIndexes,
event.toolCall,
event.success ? { output: event.output } : { error: event.error },
);
options.onToolResult!(event.toolCall.toolName, event.success ? event.output : undefined);
}
: (event) => {
recordToolCall(
toolCalls,
toolCallIndexes,
event.toolCall,
event.success ? { output: event.output } : { error: event.error },
);
},
onStepFinish: ({ usage, toolCalls: stepToolCalls, toolResults, content }) => {
stepCount++;
totalInput += tokenTotal((usage as any)?.inputTokens);
totalOutput += tokenTotal((usage as any)?.outputTokens);
totalCacheRead += tokenCacheRead((usage as any)?.inputTokens);
totalCacheWrite += tokenCacheWrite((usage as any)?.inputTokens);
for (const toolCall of stepToolCalls) {
recordToolCall(toolCalls, toolCallIndexes, toolCall);
}
for (const toolResult of toolResults) {
recordToolCall(toolCalls, toolCallIndexes, toolResult, { output: (toolResult as any).output });
}
for (const part of content) {
if ((part as any).type === 'tool-error') {
recordToolCall(toolCalls, toolCallIndexes, part, { error: (part as any).error });
}
}
},
});
// Consume the stream and collect results
const text = await result.text;
const finishReason = await result.finishReason;
const responseData = await result.response;
const responseMessages = responseData.messages as plugins.ModelMessage[];
for (const [id, reasoningText] of reasoningTextById) {
options.onReasoningEnd?.(id, reasoningText);
reasoningTextById.delete(id);
}
attempt = 0; // reset on success
const runResult: IAgentRunResult = {
text,
messages: responseMessages,
steps: stepCount,
finishReason,
usage: {
inputTokens: totalInput,
outputTokens: totalOutput,
totalTokens: totalInput + totalOutput,
cacheReadTokens: totalCacheRead,
cacheWriteTokens: totalCacheWrite,
},
toolCalls,
};
if (options.validateCompletion) {
const validationPrompt = await options.validateCompletion(runResult);
if (typeof validationPrompt === 'string') {
if (validationRetries >= (options.maxValidationRetries ?? 0)) {
throw new Error(`Agent completion validation failed: ${validationPrompt}`);
}
validationRetries++;
messages = [
...messages,
...responseMessages,
{ role: 'user' as const, content: validationPrompt },
];
continue;
}
}
return runResult;
} 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;
}
}
}
-728
View File
@@ -1,728 +0,0 @@
import * as plugins from './plugins.js';
import * as interfaces from './smartagent.interfaces.js';
import type { BaseToolWrapper } from './smartagent.tools.base.js';
/**
* Options for configuring the DriverAgent
*/
export interface IDriverAgentOptions {
/** Custom system message for the driver */
systemMessage?: string;
/** Maximum history messages to pass to API (default: 20). Set to 0 for unlimited. */
maxHistoryMessages?: number;
/** Callback fired for each token during LLM generation */
onToken?: (token: string) => void;
}
/**
* DriverAgent - Executes tasks by reasoning and proposing tool calls
* Works in conjunction with GuardianAgent for approval
*/
export class DriverAgent {
private provider: plugins.smartai.MultiModalModel;
private systemMessage: string;
private maxHistoryMessages: number;
private messageHistory: plugins.smartai.ChatMessage[] = [];
private tools: Map<string, BaseToolWrapper> = new Map();
private onToken?: (token: string) => void;
private isInThinkingMode = false; // Track thinking/content state for markers
constructor(
provider: plugins.smartai.MultiModalModel,
options?: IDriverAgentOptions | string
) {
this.provider = provider;
// Support both legacy string systemMessage and new options object
if (typeof options === 'string') {
this.systemMessage = options || this.getDefaultSystemMessage();
this.maxHistoryMessages = 20;
} else {
this.systemMessage = options?.systemMessage || this.getDefaultSystemMessage();
this.maxHistoryMessages = options?.maxHistoryMessages ?? 20;
this.onToken = options?.onToken;
}
}
/**
* Set the token callback for streaming mode
* @param callback Function to call for each generated token
*/
public setOnToken(callback: (token: string) => void): void {
this.onToken = callback;
}
/**
* Register a tool for use by the driver
*/
public registerTool(tool: BaseToolWrapper): void {
this.tools.set(tool.name, tool);
}
/**
* Get all registered tools
*/
public getTools(): Map<string, BaseToolWrapper> {
return this.tools;
}
/**
* Initialize a new conversation for a task
* @param task The task description
* @param images Optional base64-encoded images for vision tasks
*/
public async startTask(task: string, images?: string[]): Promise<interfaces.IAgentMessage> {
// Reset message history
this.messageHistory = [];
// Build the user message based on available tools
const hasTools = this.tools.size > 0;
let userMessage: string;
if (hasTools) {
userMessage = `TASK: ${task}\n\nAnalyze this task and determine what actions are needed. If you need to use a tool, provide a tool call proposal.`;
} else {
userMessage = `TASK: ${task}\n\nComplete this task directly. When done, wrap your final output in <task_complete>your output here</task_complete> tags.`;
}
// Add to history
this.messageHistory.push({
role: 'user',
content: userMessage,
});
// Build the system message - adapt based on available tools
let fullSystemMessage: string;
if (hasTools) {
const toolDescriptions = this.buildToolDescriptions();
fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
} else {
// Use a simpler system message when no tools are available
fullSystemMessage = this.getNoToolsSystemMessage();
}
// Get response from provider - use streaming if available and callback is set
let response: plugins.smartai.ChatResponse;
if (this.onToken && typeof (this.provider as any).chatStreaming === 'function') {
// Use streaming mode with token callback
response = await (this.provider as any).chatStreaming({
systemMessage: fullSystemMessage,
userMessage: userMessage,
messageHistory: [],
images: images,
onToken: this.onToken,
});
} else {
// Fallback to non-streaming mode
response = await this.provider.chat({
systemMessage: fullSystemMessage,
userMessage: userMessage,
messageHistory: [],
images: images,
});
}
// Add assistant response to history (store images if provided, preserve reasoning for GPT-OSS)
const historyMessage: plugins.smartai.ChatMessage = {
role: 'assistant',
content: response.message,
reasoning: response.reasoning,
};
this.messageHistory.push(historyMessage);
return {
role: 'assistant',
content: response.message,
};
}
/**
* Continue the conversation with feedback or results
*/
public async continueWithMessage(message: string): Promise<interfaces.IAgentMessage> {
// Add the new message to history
this.messageHistory.push({
role: 'user',
content: message,
});
// Build the system message - adapt based on available tools
const hasTools = this.tools.size > 0;
let fullSystemMessage: string;
if (hasTools) {
const toolDescriptions = this.buildToolDescriptions();
fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
} else {
fullSystemMessage = this.getNoToolsSystemMessage();
}
// Get response from provider with history windowing
// Keep original task and most recent messages to avoid token explosion
let historyForChat: plugins.smartai.ChatMessage[];
const fullHistory = this.messageHistory.slice(0, -1); // Exclude the just-added message
if (this.maxHistoryMessages > 0 && fullHistory.length > this.maxHistoryMessages) {
// Keep the original task (first message) and most recent messages
historyForChat = [
fullHistory[0], // Original task
...fullHistory.slice(-(this.maxHistoryMessages - 1)), // Recent messages
];
} else {
historyForChat = fullHistory;
}
// Get response from provider - use streaming if available and callback is set
let response: plugins.smartai.ChatResponse;
if (this.onToken && typeof (this.provider as any).chatStreaming === 'function') {
// Use streaming mode with token callback
response = await (this.provider as any).chatStreaming({
systemMessage: fullSystemMessage,
userMessage: message,
messageHistory: historyForChat,
onToken: this.onToken,
});
} else {
// Fallback to non-streaming mode
response = await this.provider.chat({
systemMessage: fullSystemMessage,
userMessage: message,
messageHistory: historyForChat,
});
}
// Add assistant response to history (preserve reasoning for GPT-OSS)
this.messageHistory.push({
role: 'assistant',
content: response.message,
reasoning: response.reasoning,
});
return {
role: 'assistant',
content: response.message,
};
}
/**
* Parse tool call proposals from assistant response
*/
public parseToolCallProposals(response: string): interfaces.IToolCallProposal[] {
const proposals: interfaces.IToolCallProposal[] = [];
// Match <tool_call>...</tool_call> blocks
const toolCallRegex = /<tool_call>([\s\S]*?)<\/tool_call>/g;
let match;
while ((match = toolCallRegex.exec(response)) !== null) {
const content = match[1];
try {
const proposal = this.parseToolCallContent(content);
if (proposal) {
proposals.push(proposal);
}
} catch (error) {
// Skip malformed tool calls
console.warn('Failed to parse tool call:', error);
}
}
return proposals;
}
/**
* Parse the content inside a tool_call block
*/
private parseToolCallContent(content: string): interfaces.IToolCallProposal | null {
// Extract tool name
const toolMatch = content.match(/<tool>(.*?)<\/tool>/s);
if (!toolMatch) return null;
const toolName = toolMatch[1].trim();
// Extract action
const actionMatch = content.match(/<action>(.*?)<\/action>/s);
if (!actionMatch) return null;
const action = actionMatch[1].trim();
// Extract params (JSON)
const paramsMatch = content.match(/<params>([\s\S]*?)<\/params>/);
let params: Record<string, unknown> = {};
if (paramsMatch) {
try {
params = JSON.parse(paramsMatch[1].trim());
} catch {
// Try to extract individual parameters if JSON fails
params = this.extractParamsFromXml(paramsMatch[1]);
}
}
// Extract reasoning (optional)
const reasoningMatch = content.match(/<reasoning>([\s\S]*?)<\/reasoning>/);
const reasoning = reasoningMatch ? reasoningMatch[1].trim() : undefined;
return {
proposalId: this.generateProposalId(),
toolName,
action,
params,
reasoning,
};
}
/**
* Extract parameters from XML-like format when JSON parsing fails
*/
private extractParamsFromXml(content: string): Record<string, unknown> {
const params: Record<string, unknown> = {};
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/g;
let match;
while ((match = paramRegex.exec(content)) !== null) {
const key = match[1];
let value: unknown = match[2].trim();
// Try to parse as JSON for arrays/objects
try {
value = JSON.parse(value as string);
} catch {
// Keep as string if not valid JSON
}
params[key] = value;
}
return params;
}
/**
* Check if the response indicates task completion
*/
public isTaskComplete(response: string): boolean {
// Check for explicit completion markers
const completionMarkers = [
'<task_complete>',
'<task_completed>',
'TASK COMPLETE',
'Task completed successfully',
];
const lowerResponse = response.toLowerCase();
return completionMarkers.some(marker =>
lowerResponse.includes(marker.toLowerCase())
);
}
/**
* Check if the response needs clarification or user input
*/
public needsClarification(response: string): boolean {
const clarificationMarkers = [
'<needs_clarification>',
'<question>',
'please clarify',
'could you specify',
'what do you mean by',
];
const lowerResponse = response.toLowerCase();
return clarificationMarkers.some(marker =>
lowerResponse.includes(marker.toLowerCase())
);
}
/**
* Extract the final result from a completed task
*/
public extractTaskResult(response: string): string | null {
// Try to extract from result tags
const resultMatch = response.match(/<task_result>([\s\S]*?)<\/task_result>/);
if (resultMatch) {
return resultMatch[1].trim();
}
const completeMatch = response.match(/<task_complete>([\s\S]*?)<\/task_complete>/);
if (completeMatch) {
return completeMatch[1].trim();
}
return null;
}
/**
* Build tool descriptions for the system message
*/
private buildToolDescriptions(): string {
const descriptions: string[] = [];
for (const tool of this.tools.values()) {
descriptions.push(tool.getFullDescription());
}
return descriptions.join('\n\n');
}
/**
* Generate a unique proposal ID
*/
private generateProposalId(): string {
return `prop_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}
/**
* Get the default system message for the driver
*/
private getDefaultSystemMessage(): string {
return `You are an AI assistant that executes tasks by using available tools.
## Your Role
You analyze tasks, break them down into steps, and use tools to accomplish goals.
## CRITICAL: Tool Usage Format
To use a tool, you MUST literally write out the XML tags in your response. The system parses your output looking for these exact tags. Do NOT just describe or mention the tool call - you must OUTPUT the actual XML.
CORRECT (the XML is in the output):
<tool_call>
<tool>json</tool>
<action>validate</action>
<params>{"jsonString": "{\\"key\\":\\"value\\"}"}</params>
</tool_call>
WRONG (just describing, no actual XML):
"I will call json.validate now" or "Let me use the tool"
## Guidelines
1. Think step by step about what needs to be done
2. When you need a tool, OUTPUT the <tool_call> XML tags - do not just mention them
3. Only propose ONE tool call at a time
4. Wait for the result before proposing the next action
5. When the task is complete, OUTPUT:
<task_complete>
Your final result here
</task_complete>
## Important
- The <tool_call> and <task_complete> tags MUST appear literally in your response
- If you just say "I'll call the tool" without the actual XML, it will NOT work
- If you need clarification, ask using <needs_clarification>your question</needs_clarification>`;
}
/**
* Get the system message when no tools are available
* Used for direct task completion without tool usage
*/
private getNoToolsSystemMessage(): string {
// Use custom system message if provided, otherwise use a simple default
if (this.systemMessage && this.systemMessage !== this.getDefaultSystemMessage()) {
return this.systemMessage;
}
return `You are an AI assistant that completes tasks directly.
## Your Role
You analyze tasks and provide complete, high-quality outputs.
## Output Format
When you have completed the task, wrap your final output in task_complete tags:
<task_complete>
Your complete output here
</task_complete>
## Guidelines
1. Analyze the task requirements carefully
2. Provide a complete and accurate response
3. Always wrap your final output in <task_complete></task_complete> tags
4. If you need clarification, ask using <needs_clarification>your question</needs_clarification>`;
}
/**
* Reset the conversation state
*/
public reset(): void {
this.messageHistory = [];
}
// ================================
// Native Tool Calling Support
// ================================
/**
* Start a task with native tool calling support
* Uses Ollama's native tool calling API instead of XML parsing
* @param task The task description
* @param images Optional base64-encoded images for vision tasks
* @returns Response with content, reasoning, and any tool calls
*/
public async startTaskWithNativeTools(
task: string,
images?: string[]
): Promise<{ message: interfaces.IAgentMessage; toolCalls?: interfaces.INativeToolCall[] }> {
// Reset message history
this.messageHistory = [];
// Build simple user message (no XML instructions needed for native tool calling)
const userMessage = `TASK: ${task}\n\nComplete this task using the available tools. When done, provide your final output.`;
// Add to history
this.messageHistory.push({
role: 'user',
content: userMessage,
});
// Build system message for native tool calling
const fullSystemMessage = this.getNativeToolsSystemMessage();
// Get tools in JSON schema format
const tools = this.getToolsAsJsonSchema();
// Check if provider supports native tool calling (Ollama)
const provider = this.provider as any;
if (typeof provider.collectStreamResponse !== 'function') {
throw new Error('Provider does not support native tool calling. Use startTask() instead.');
}
// Use collectStreamResponse for streaming support with tools
const response = await provider.collectStreamResponse(
{
systemMessage: fullSystemMessage,
userMessage: userMessage,
messageHistory: [],
images: images,
tools: tools.length > 0 ? tools : undefined,
},
// Pass onToken callback through onChunk for streaming with thinking markers
this.onToken ? (chunk: any) => {
if (chunk.thinking && this.onToken) {
// Add marker only when transitioning INTO thinking mode
if (!this.isInThinkingMode) {
this.onToken('\n[THINKING] ');
this.isInThinkingMode = true;
}
this.onToken(chunk.thinking);
}
if (chunk.content && this.onToken) {
// Add marker when transitioning OUT of thinking mode
if (this.isInThinkingMode) {
this.onToken('\n[OUTPUT] ');
this.isInThinkingMode = false;
}
this.onToken(chunk.content);
}
} : undefined
);
// Reset thinking state after response completes
this.isInThinkingMode = false;
// Add assistant response to history
const historyMessage: 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) {
// Add marker only when transitioning INTO thinking mode
if (!this.isInThinkingMode) {
this.onToken('\n[THINKING] ');
this.isInThinkingMode = true;
}
this.onToken(chunk.thinking);
}
if (chunk.content && this.onToken) {
// Add marker when transitioning OUT of thinking mode
if (this.isInThinkingMode) {
this.onToken('\n[OUTPUT] ');
this.isInThinkingMode = false;
}
this.onToken(chunk.content);
}
} : undefined
);
// Reset thinking state after response completes
this.isInThinkingMode = false;
// Add assistant response to history
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,
};
});
}
}
-654
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());
}
}
-241
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;
}
}
+20
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 };
}
}
+89 -294
View File
@@ -1,306 +1,101 @@
import * as plugins from './plugins.js';
import type {
ISmartAiCacheOptions,
ToolSet,
ModelMessage,
LanguageModelV3,
ProviderOptions,
TSmartAiCacheRetention,
TSmartAiCacheSetting,
} from './plugins.js';
// ================================
// Task Run Options
// ================================
export type { ProviderOptions };
export type IAgentCacheOptions = ISmartAiCacheOptions;
export type TAgentCacheRetention = TSmartAiCacheRetention;
export type TAgentCacheSetting = TSmartAiCacheSetting;
/**
* 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[];
export interface IAgentToolCallRecord {
toolName: string;
input: unknown;
output?: unknown;
error?: 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;
/** Provider-specific AI SDK request options passed through to streamText() */
providerOptions?: ProviderOptions;
/** Stable session id used as provider prompt-cache affinity key where supported. */
sessionId?: string;
/** Prompt-cache policy. Default: 'auto'. Set false to disable smartagent cache defaults. */
cache?: TAgentCacheSetting;
/**
* 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 the model starts a streamed reasoning summary */
onReasoningStart?: (id: string, providerMetadata?: unknown) => void;
/** Called for each streamed reasoning summary delta */
onReasoningDelta?: (id: string, delta: string, providerMetadata?: unknown) => void;
/** Called when a streamed reasoning summary completes */
onReasoningEnd?: (id: string, text: string, providerMetadata?: unknown) => 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;
/**
* Validate the completed run. Return a string to reject the run and reprompt,
* or return void to accept the result.
*/
validateCompletion?: (result: IAgentRunResult) => Promise<string | void> | string | void;
/** Number of validation-triggered reprompts allowed. Default: 0 */
maxValidationRetries?: number;
/**
* 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;
}
// ================================
// 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;
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;
cacheReadTokens: number;
cacheWriteTokens: number;
};
/** Tool calls observed during the run, including inputs and outputs/errors when available */
toolCalls: IAgentToolCallRecord[];
}
/**
* 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';
}
}
-83
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;
}
}
-253
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}`;
}
}
}
-230
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}`;
}
}
}
-885
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}`;
}
}
}
-283
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;
}
}
-224
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}`;
}
}
}
-230
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}`;
}
}
}
+39
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
View File
@@ -0,0 +1 @@
export { compactMessages } from './smartagent.compaction.js';
+6
View File
@@ -0,0 +1,6 @@
import { generateText } from 'ai';
export { generateText };
export type { ModelMessage } from 'ai';
export type { LanguageModelV3 } from '@push.rocks/smartai';
+51
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.]`,
},
];
}
+25
View File
@@ -0,0 +1,25 @@
export { createBrowserTools } from './tool.browser.js';
export type { ICreateBrowserToolsOptions } from './tool.browser.js';
export { createFilesystemTools, filesystemTool } from './tool.filesystem.js';
export type { ICreateFilesystemToolsOptions, IFilesystemToolOptions } from './tool.filesystem.js';
export { createShellTools, shellTool } from './tool.shell.js';
export type { ICreateShellToolsOptions, IShellToolOptions } from './tool.shell.js';
export { createLocalToolExecutionContext, formatShellResult, formatToolOutput } from './tool.context.js';
export type {
IBrowserToolInput,
ILocalToolExecutionContextOptions,
IToolBrowserContext,
IToolExecutionContext,
IToolFilesystemContext,
IToolFilesystemListOptions,
IToolFilesystemReadOptions,
IToolPermissionRequest,
IToolRunOptions,
IToolShellContext,
IToolShellResult,
TBrowserToolAction,
} from './tool.context.js';
export { httpTool } from './tool.http.js';
export { jsonTool } from './tool.json.js';
export { truncateOutput } from './plugins.js';
export type { ITruncateResult } from './plugins.js';
+31
View File
@@ -0,0 +1,31 @@
// node native
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
export { childProcess, fs, path };
// 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';
+94
View File
@@ -0,0 +1,94 @@
import * as plugins from './plugins.js';
import {
formatToolOutput,
type IBrowserToolInput,
type IToolExecutionContext,
type TBrowserToolAction,
} from './tool.context.js';
export interface ICreateBrowserToolsOptions {
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
const browserActions = new Set<TBrowserToolAction>(['navigate', 'snapshot', 'screenshot', 'click', 'fill', 'press', 'evaluate', 'close']);
export function createBrowserTools(context: IToolExecutionContext, options: ICreateBrowserToolsOptions = {}): plugins.ToolSet {
return {
browser: plugins.tool({
description: [
'Control a browser supplied by the host execution context for web UI inspection and interaction.',
'Actions: navigate, snapshot, screenshot, click, fill, press, evaluate, close.',
'Use snapshot after navigation or interaction to inspect page text and interactive selectors before choosing the next action.',
'Actions that navigate or modify page state require host permission when configured.',
].join(' '),
inputSchema: plugins.z.object({
action: plugins.z.string().default('snapshot').describe('Action: navigate, snapshot, screenshot, click, fill, press, evaluate, or close'),
url: plugins.z.string().optional().describe('URL for navigate'),
selector: plugins.z.string().optional().describe('CSS or Playwright selector for click/fill'),
text: plugins.z.string().optional().describe('Text for fill, key name for press, or screenshot mode/full-page hint'),
script: plugins.z.string().optional().describe('JavaScript expression or function body for evaluate'),
timeoutMs: plugins.z.number().optional().describe('Optional action timeout in milliseconds'),
}),
execute: async (input: IBrowserToolInput) => {
if (!context.browser) {
throw new Error('Browser tool is not available in this execution context.');
}
const action = normalizeBrowserAction(input.action);
await requestBrowserPermission(context, { ...input, action });
const result = await context.browser.execute({ ...input, action }, {
timeoutMs: input.timeoutMs,
abortSignal: context.abortSignal,
});
return plugins.truncateOutput(formatToolOutput(result), {
maxLines: options.maxLines,
maxBytes: options.maxBytes,
}).content;
},
}),
};
}
const normalizeBrowserAction = (input: unknown): TBrowserToolAction => {
const action = typeof input === 'string' && input.trim()
? input.trim().toLowerCase()
: 'snapshot';
if (browserActions.has(action as TBrowserToolAction)) {
return action as TBrowserToolAction;
}
throw new Error(`Unsupported browser action: ${String(input)}. Use one of: ${[...browserActions].join(', ')}.`);
};
const requestBrowserPermission = async (context: IToolExecutionContext, input: IBrowserToolInput & { action: TBrowserToolAction }): Promise<void> => {
if (!context.requestPermission) return;
if (input.action === 'snapshot' || input.action === 'screenshot') return;
const titleByAction: Record<TBrowserToolAction, string> = {
navigate: 'Navigate browser',
snapshot: 'Inspect browser',
screenshot: 'Capture browser screenshot',
click: 'Click browser element',
fill: 'Fill browser element',
press: 'Press browser key',
evaluate: 'Evaluate browser JavaScript',
close: 'Close browser session',
};
await context.requestPermission({
type: 'browser',
title: titleByAction[input.action],
metadata: {
action: input.action,
url: input.url,
selector: input.selector,
key: input.action === 'press' ? input.text : undefined,
textLength: input.action === 'fill' ? input.text?.length ?? 0 : undefined,
scriptPreview: input.action === 'evaluate' && input.script ? compactMetadataText(input.script) : undefined,
},
});
};
const compactMetadataText = (text: string): string => {
const compacted = text.replace(/\s+/g, ' ').trim();
return compacted.length > 160 ? `${compacted.slice(0, 157)}...` : compacted;
};
+207
View File
@@ -0,0 +1,207 @@
import * as plugins from './plugins.js';
export interface IToolPermissionRequest {
type: string;
title: string;
metadata?: Record<string, unknown>;
}
export interface IToolRunOptions {
cwd?: string;
timeoutMs?: number;
abortSignal?: AbortSignal;
}
export interface IToolShellResult {
exitCode: number;
stdout: string;
stderr?: string;
signal?: string;
}
export interface IToolShellContext {
run(command: string, options?: IToolRunOptions): Promise<IToolShellResult | string>;
}
export interface IToolFilesystemReadOptions {
startLine?: number;
endLine?: number;
}
export interface IToolFilesystemListOptions {
recursive?: boolean;
}
export interface IToolFilesystemContext {
readFile(filePath: string, options?: IToolFilesystemReadOptions): Promise<string>;
writeFile(filePath: string, content: string): Promise<string | void>;
listDirectory(directoryPath: string, options?: IToolFilesystemListOptions): Promise<string[] | string>;
deletePath?(targetPath: string): Promise<string | void>;
}
export type TBrowserToolAction = 'navigate' | 'snapshot' | 'screenshot' | 'click' | 'fill' | 'press' | 'evaluate' | 'close';
export interface IBrowserToolInput {
action?: TBrowserToolAction | string;
url?: string;
selector?: string;
text?: string;
script?: string;
timeoutMs?: number;
}
export interface IToolBrowserContext {
execute(input: IBrowserToolInput, options?: { timeoutMs?: number; abortSignal?: AbortSignal }): Promise<unknown>;
}
export interface IToolExecutionContext {
cwd?: string;
rootDir?: string;
abortSignal?: AbortSignal;
shell?: IToolShellContext;
fs?: IToolFilesystemContext;
browser?: IToolBrowserContext;
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
}
export interface ILocalToolExecutionContextOptions {
cwd?: string;
rootDir?: string;
abortSignal?: AbortSignal;
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
}
export const createLocalToolExecutionContext = (options: ILocalToolExecutionContextOptions = {}): IToolExecutionContext => {
const cwd = options.cwd ?? process.cwd();
const rootDir = options.rootDir;
return {
cwd,
rootDir,
abortSignal: options.abortSignal,
requestPermission: options.requestPermission,
shell: {
run: (command, runOptions) => runLocalShellCommand(command, {
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
timeoutMs: runOptions?.timeoutMs,
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
}),
},
fs: {
readFile: async (filePath, readOptions) => {
const resolved = resolveLocalPath(filePath, rootDir, cwd);
const content = await plugins.fs.promises.readFile(resolved, 'utf8');
if (readOptions?.startLine !== undefined || readOptions?.endLine !== undefined) {
const lines = content.split('\n');
const start = Math.max((readOptions.startLine ?? 1) - 1, 0);
const end = Math.max(readOptions.endLine ?? lines.length, start);
return lines.slice(start, end).join('\n');
}
return content;
},
writeFile: async (filePath, content) => {
const resolved = resolveLocalPath(filePath, rootDir, cwd);
await plugins.fs.promises.mkdir(plugins.path.dirname(resolved), { recursive: true });
await plugins.fs.promises.writeFile(resolved, content, 'utf8');
return `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`;
},
listDirectory: async (directoryPath, listOptions) => {
const resolved = resolveLocalPath(directoryPath, rootDir, cwd);
return listLocalDirectory(resolved, !!listOptions?.recursive);
},
deletePath: async (targetPath) => {
const resolved = resolveLocalPath(targetPath, rootDir, cwd);
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
return `Deleted ${targetPath}`;
},
},
};
};
export const formatShellResult = (result: IToolShellResult | string): string => {
if (typeof result === 'string') return result;
if (result.exitCode === 0) return result.stdout;
return [
`Exit code: ${result.exitCode}`,
result.signal ? `Signal: ${result.signal}` : '',
`stdout:\n${result.stdout}`,
`stderr:\n${result.stderr ?? ''}`,
].filter(Boolean).join('\n');
};
export const formatToolOutput = (output: unknown): string => {
if (typeof output === 'string') return output;
try {
return JSON.stringify(output, undefined, 2);
} catch {
return String(output);
}
};
const resolveLocalPath = (targetPath: string, rootDir?: string, baseDir?: string): string => {
const base = rootDir ?? baseDir ?? process.cwd();
const resolved = plugins.path.isAbsolute(targetPath)
? plugins.path.resolve(targetPath)
: plugins.path.resolve(base, targetPath);
if (rootDir) {
const resolvedRoot = plugins.path.resolve(rootDir);
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + plugins.path.sep)) {
throw new Error(`Access denied: "${targetPath}" is outside allowed root "${rootDir}"`);
}
}
return resolved;
};
const listLocalDirectory = async (directoryPath: string, recursive: boolean): Promise<string[]> => {
const entries = await plugins.fs.promises.readdir(directoryPath, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const relativePath = entry.name + (entry.isDirectory() ? '/' : '');
result.push(relativePath);
if (recursive && entry.isDirectory()) {
const childEntries = await listLocalDirectory(plugins.path.join(directoryPath, entry.name), true);
result.push(...childEntries.map((childEntry) => `${entry.name}/${childEntry}`));
}
}
return result;
};
const runLocalShellCommand = async (command: string, options: IToolRunOptions): Promise<IToolShellResult> => {
return new Promise<IToolShellResult>((resolve) => {
const child = plugins.childProcess.spawn('bash', ['-lc', command], {
cwd: options.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timeout = options.timeoutMs && options.timeoutMs > 0
? setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, options.timeoutMs)
: undefined;
const abort = () => child.kill('SIGTERM');
options.abortSignal?.addEventListener('abort', abort, { once: true });
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
options.abortSignal?.removeEventListener('abort', abort);
resolve({ exitCode: 1, stdout, stderr: `${stderr}${error.message}` });
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
options.abortSignal?.removeEventListener('abort', abort);
resolve({
exitCode: code ?? (timedOut ? 124 : 1),
stdout,
stderr: timedOut ? `${stderr}\nCommand timed out after ${options.timeoutMs}ms.`.trim() : stderr,
signal: signal ?? undefined,
});
});
});
};
+133
View File
@@ -0,0 +1,133 @@
import * as plugins from './plugins.js';
import {
createLocalToolExecutionContext,
formatToolOutput,
type IToolExecutionContext,
} from './tool.context.js';
export interface IFilesystemToolOptions {
/** Restrict file access to this directory. Default: process.cwd() */
rootDir?: string;
/** Execution context. Defaults to a local Node.js context. */
context?: IToolExecutionContext;
/** Include delete_file. Default: true for compatibility. */
includeDelete?: boolean;
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export interface ICreateFilesystemToolsOptions {
/** Include delete_file. Default: true. */
includeDelete?: boolean;
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export function createFilesystemTools(context: IToolExecutionContext, options: ICreateFilesystemToolsOptions = {}): plugins.ToolSet {
const truncate = (output: unknown) => plugins.truncateOutput(formatToolOutput(output), {
maxLines: options.maxLines,
maxBytes: options.maxBytes,
}).content;
const tools: plugins.ToolSet = {
read_file: plugins.tool({
description:
'Read a UTF-8 file in the active workspace. Paths may be absolute or relative to the workspace root.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('File path'),
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;
}) => {
if (!context.fs) {
throw new Error('Filesystem tools are not available in this execution context.');
}
return truncate(await context.fs.readFile(filePath, { startLine, endLine }));
},
}),
write_file: plugins.tool({
description:
'Write UTF-8 content to a file in the active workspace. Creates parent directories and overwrites existing content. Requires host permission when configured.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('File path'),
content: plugins.z.string().describe('Complete file content to write'),
}),
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
if (!context.fs) {
throw new Error('Filesystem tools are not available in this execution context.');
}
await context.requestPermission?.({
type: 'write',
title: 'Write file',
metadata: { path: filePath, bytes: Buffer.byteLength(content, 'utf8') },
});
const result = await context.fs.writeFile(filePath, content);
return truncate(result ?? `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`);
},
}),
list_directory: plugins.tool({
description: 'List files and directories in the active workspace. Paths may be absolute or relative to the workspace root.',
inputSchema: plugins.z.object({
path: plugins.z.string().default('.').describe('Directory path to list'),
recursive: plugins.z
.boolean()
.optional()
.describe('List recursively. Default: false'),
}),
execute: async ({ path: directoryPath, recursive }: { path: string; recursive?: boolean }) => {
if (!context.fs) {
throw new Error('Filesystem tools are not available in this execution context.');
}
const result = await context.fs.listDirectory(directoryPath, { recursive });
return truncate(Array.isArray(result) ? result.join('\n') : result);
},
}),
};
if (options.includeDelete !== false) {
tools.delete_file = plugins.tool({
description: 'Delete a file or empty directory in the active workspace. Requires host permission when configured.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Path to delete'),
}),
execute: async ({ path: targetPath }: { path: string }) => {
if (!context.fs?.deletePath) {
throw new Error('Deleting files is not available in this execution context.');
}
await context.requestPermission?.({
type: 'delete',
title: 'Delete file',
metadata: { path: targetPath },
});
const result = await context.fs.deletePath(targetPath);
return truncate(result ?? `Deleted ${targetPath}`);
},
});
}
return tools;
}
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
const context = options?.context ?? createLocalToolExecutionContext({ rootDir: options?.rootDir });
return createFilesystemTools(context, options);
}
+78
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(), 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.string(), plugins.z.unknown())
.optional()
.describe('JSON body to send'),
headers: plugins.z
.record(plugins.z.string(), 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
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}`;
}
},
}),
};
}
+96
View File
@@ -0,0 +1,96 @@
import * as plugins from './plugins.js';
import {
createLocalToolExecutionContext,
formatShellResult,
type IToolExecutionContext,
} from './tool.context.js';
export interface IShellToolOptions {
/** Allowed commands whitelist. If empty, all commands are allowed. */
allowedCommands?: string[];
/** Working directory for shell execution */
cwd?: string;
/** Execution context. Defaults to a local Node.js context. */
context?: IToolExecutionContext;
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export interface ICreateShellToolsOptions {
/** Allowed commands whitelist. If empty, all commands are allowed. */
allowedCommands?: string[];
/** Maximum output lines before truncating. */
maxLines?: number;
/** Maximum output bytes before truncating. */
maxBytes?: number;
}
export function createShellTools(context: IToolExecutionContext, options: ICreateShellToolsOptions = {}): plugins.ToolSet {
return {
run_command: plugins.tool({
description:
'Execute a shell command in the active workspace. 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'),
timeoutMs: plugins.z
.number()
.optional()
.describe('Timeout in milliseconds'),
}),
execute: async ({
command,
cwd,
timeout,
timeoutMs,
}: {
command: string;
cwd?: string;
timeout?: number;
timeoutMs?: number;
}) => {
if (!context.shell) {
throw new Error('Shell tool is not available in this execution context.');
}
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(', ')}`;
}
}
await context.requestPermission?.({
type: 'shell',
title: 'Run shell command',
metadata: { command, cwd: cwd ?? context.cwd },
});
const execResult = await context.shell.run(command, {
cwd: cwd ?? context.cwd,
timeoutMs: timeoutMs ?? timeout,
abortSignal: context.abortSignal,
});
return plugins.truncateOutput(formatShellResult(execResult), {
maxLines: options.maxLines,
maxBytes: options.maxBytes,
}).content;
},
}),
};
}
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
const context = options?.context ?? createLocalToolExecutionContext({ cwd: options?.cwd });
return createShellTools(context, options);
}
+2 -2
View File
@@ -3,10 +3,10 @@
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noImplicitAny": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"paths": {}
"types": ["node"]
},
"exclude": ["dist_*/**/*.d.ts"]
}