54 Commits

Author SHA1 Message Date
2947842499 v3.0.1
Some checks failed
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
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
38556c8b12 v3.0.0
Some checks failed
Default (tags) / security (push) Successful in 31s
Default (tags) / test (push) Failing after 4m1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-06 11:39:01 +00:00
f9a9c9fb48 BREAKING CHANGE(api): Migrate public API to ai-sdk v6 and refactor core agent architecture: replace class-based DualAgent/Driver/Guardian with a single runAgent function; introduce ts_tools factories for tools, a compactMessages compaction subpath, and truncateOutput utility; simplify ToolRegistry to return ToolSet and remove legacy BaseToolWrapper/tool classes; update package exports and dependencies and bump major version. 2026-03-06 11:39:01 +00:00
903de44644 v1.8.0
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 14:39:34 +00:00
5aa69cc998 feat(tools): add ToolRegistry, ToolSearchTool and ExpertTool to support on-demand tool visibility, discovery, activation, and expert/subagent tooling; extend DualAgentOrchestrator API and interfaces to manage tool lifecycle 2026-01-20 14:39:34 +00:00
5ca0c80ea9 v1.7.0
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 12:01:07 +00:00
940bf3d3ef feat(docs): document native tool calling support and update README to clarify standard and additional tools 2026-01-20 12:01:07 +00:00
c1b269f301 v1.6.2
Some checks failed
Default (tags) / security (push) Successful in 34s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:56:44 +00:00
7cb970f9e2 fix(release): bump version to 1.6.2 2026-01-20 03:56:44 +00:00
1fbcf8bb8b fix(driveragent): save tool_calls in message history for native tool calling
When using native tool calling, the assistant's tool_calls must be saved
in message history. Without this, the model doesn't know it already called
a tool and loops indefinitely calling the same tool.

This fix saves tool_calls in both startTaskWithNativeTools and
continueWithNativeTools methods.

Also updates @push.rocks/smartai to v0.13.3 for tool_calls forwarding support.
2026-01-20 03:56:10 +00:00
4a8789019a v1.6.1
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:38:07 +00:00
0da85a5dcd fix(driveragent): include full message history for tool results and use a continuation prompt when invoking provider.collectStreamResponse 2026-01-20 03:38:07 +00:00
121e216eea v1.6.0
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:28:59 +00:00
eb1058bfb5 feat(smartagent): record native tool results in message history by adding optional toolName to continueWithNativeTools and passing tool identifier from DualAgent 2026-01-20 03:28:59 +00:00
ecdc125a43 v1.5.4
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:16:02 +00:00
73657be550 fix(driveragent): prevent duplicate thinking/output markers during token streaming and mark transitions 2026-01-20 03:16:02 +00:00
4e4d3c0e08 v1.5.3
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 40s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 03:10:53 +00:00
79efe8f6b8 fix(driveragent): prefix thinking tokens with [THINKING] when forwarding streaming chunks to onToken 2026-01-20 03:10:53 +00:00
8bcf3257e2 v1.5.2
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 02:54:58 +00:00
6753553394 fix(): no changes in this diff; nothing to release 2026-01-20 02:54:58 +00:00
a46dbd0da6 fix(driveragent): enable streaming for native tool calling methods 2026-01-20 02:54:45 +00:00
7379daf4c5 v1.5.1
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 02:45:41 +00:00
83422b4b0e fix(smartagent): bump patch version to 1.5.1 (no changes in diff) 2026-01-20 02:45:41 +00:00
4310c8086b feat(native-tools): add native tool calling support for Ollama models
- Add INativeToolCall interface for native tool call format
- Add useNativeToolCalling option to IDualAgentOptions
- Add getToolsAsJsonSchema() to convert tools to Ollama JSON Schema format
- Add parseNativeToolCalls() to convert native tool calls to proposals
- Add startTaskWithNativeTools() and continueWithNativeTools() to DriverAgent
- Update DualAgentOrchestrator to support both XML parsing and native tool calling modes

Native tool calling is more efficient for models like GPT-OSS that use Harmony format,
as it activates Ollama's built-in tool parser instead of requiring XML generation.
2026-01-20 02:44:54 +00:00
472a8ed7f8 v1.5.0
Some checks failed
Default (tags) / security (push) Successful in 37s
Default (tags) / test (push) Failing after 35s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 02:05:12 +00:00
44137a8710 feat(driveragent): preserve assistant reasoning in message history and update @push.rocks/smartai dependency to ^0.13.0 2026-01-20 02:05:12 +00:00
c12a6a7be9 v1.4.2
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 40s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 01:41:18 +00:00
49dcc7a1a1 fix(repo): no changes detected in diff 2026-01-20 01:41:18 +00:00
e649e9caab fix(driver): make tool call format instructions explicit about literal XML output
The system message now clearly states that the <tool_call> XML tags MUST
be literally written in the response, not just described. Includes examples
of CORRECT vs WRONG usage to help smaller models understand.
2026-01-20 01:40:57 +00:00
c39e7e76b8 v1.4.1
Some checks failed
Default (tags) / security (push) Successful in 36s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 01:36:30 +00:00
c24a4306d9 fix(): no changes detected (empty diff) 2026-01-20 01:36:30 +00:00
9718048dff fix(dualagent): improve no-tool-call feedback with explicit XML format reminder
When the LLM fails to emit a tool_call XML block, the feedback now includes
the exact XML format expected with a concrete example for json.validate.
This helps smaller models understand the exact output format required.
2026-01-20 01:36:03 +00:00
b1deccaa26 v1.4.0
Some checks failed
Default (tags) / security (push) Successful in 32s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 01:30:26 +00:00
52d1d128c7 feat(docs): document Dual-Agent Driver/Guardian architecture, new standard tools, streaming/vision support, progress events, and updated API/export docs 2026-01-20 01:30:26 +00:00
60f8bbe1b6 feat(tools): add getToolExplanation() method with XML examples for LLM tool calling
Each tool now provides comprehensive documentation including parameter
schemas and concrete <tool_call> XML examples. This helps smaller LLMs
understand the exact format required for tool invocation.
2026-01-20 01:30:03 +00:00
b6308d2113 v1.3.0
Some checks failed
Default (tags) / security (push) Successful in 35s
Default (tags) / test (push) Failing after 45s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 01:12:03 +00:00
e7968a31b1 feat(smartagent): add JsonValidatorTool and support passing base64-encoded images with task runs (vision-capable models); bump @push.rocks/smartai to ^0.12.0 2026-01-20 01:12:03 +00:00
05e4f03061 v1.2.8 2026-01-20 00:38:41 +00:00
37d4069806 feat(streaming): add streaming support to DriverAgent and DualAgentOrchestrator
- Add onToken callback option to IDualAgentOptions interface
- Add onToken property and setOnToken method to DriverAgent
- Wire up streaming in startTask and continueWithMessage methods
- Pass source identifier ('driver'/'guardian') to onToken callback
2026-01-20 00:38:36 +00:00
fe0de36b1a v1.2.7
Some checks failed
Default (tags) / security (push) Successful in 33s
Default (tags) / test (push) Failing after 36s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 00:13:32 +00:00
e49f35e7de fix(deps(smartai)): bump @push.rocks/smartai to ^0.11.0 2026-01-20 00:13:32 +00:00
4fbffdd97d v1.2.6
Some checks failed
Default (tags) / security (push) Successful in 50s
Default (tags) / test (push) Failing after 1m12s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-01-20 00:05:42 +00:00
560838477f fix(deps): bump @push.rocks/smartai to ^0.10.1 2026-01-20 00:05:42 +00:00
7503fccbf2 1.2.5
Some checks failed
Default (tags) / security (push) Successful in 52s
Default (tags) / test (push) Failing after 1m8s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 15:30:13 +00:00
a76bd0d3e4 update 2025-12-15 15:29:56 +00:00
1556a9d3e9 1.2.4
Some checks failed
Default (tags) / security (push) Successful in 54s
Default (tags) / test (push) Failing after 1m10s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 15:16:08 +00:00
19ba58ca40 update 2025-12-15 15:11:22 +00:00
8662b73adb update 2025-12-15 14:49:26 +00:00
9e848045f7 1.2.3
Some checks failed
Default (tags) / security (push) Successful in 55s
Default (tags) / test (push) Failing after 1m9s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 14:25:05 +00:00
8827a55768 1.2.2
Some checks failed
Default (tags) / security (push) Successful in 58s
Default (tags) / test (push) Failing after 1m11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 14:24:06 +00:00
37b6e98a81 improve tools 2025-12-15 14:23:53 +00:00
35911c21de 1.2.1
Some checks failed
Default (tags) / security (push) Successful in 1m0s
Default (tags) / test (push) Failing after 1m9s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-15 12:37:28 +00:00
7403e769d0 update 2025-12-15 12:37:19 +00:00
33 changed files with 4146 additions and 3609 deletions

View File

@@ -1,5 +1,143 @@
# Changelog
## 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
- Add isInThinkingMode flag to track thinking vs output state
- Emit "\n[THINKING] " only when transitioning into thinking mode (avoids repeated thinking markers)
- Emit "\n[OUTPUT] " when transitioning out of thinking mode to mark content output
- Reset thinking state after response completes to ensure correct markers for subsequent responses
- Applied the same streaming marker logic to both response handling paths
## 2026-01-20 - 1.5.3 - fix(driveragent)
prefix thinking tokens with [THINKING] when forwarding streaming chunks to onToken
- Wraps chunk.thinking with '[THINKING] ' before calling onToken to mark thinking tokens
- Forwards chunk.content unchanged
- Change applied in ts/smartagent.classes.driveragent.ts for both initial and subsequent assistant streaming responses
- No API signature changes; only the token payloads sent to onToken are altered
## 2026-01-20 - 1.5.2 - fix()
no changes in this diff; nothing to release
- No files changed; no release required
- No code or dependency changes detected
## 2026-01-20 - 1.5.1 - fix(smartagent)
bump patch version to 1.5.1 (no changes in diff)
- No code changes detected in the provided diff
- Current package.json version is 1.5.0
- Recommended semantic version bump: patch -> 1.5.1
## 2026-01-20 - 1.5.0 - feat(driveragent)
preserve assistant reasoning in message history and update @push.rocks/smartai dependency to ^0.13.0
- Store response.reasoning in messageHistory for assistant responses (two places in driveragent)
- Bump dependency @push.rocks/smartai from ^0.12.0 to ^0.13.0
## 2026-01-20 - 1.4.2 - fix(repo)
no changes detected in diff
- No files changed in diff; no code or metadata updates were made.
- No version bump required.
## 2026-01-20 - 1.4.1 - fix()
no changes detected (empty diff)
- No files changed in this commit
- No release required
## 2026-01-20 - 1.4.0 - feat(docs)
document Dual-Agent Driver/Guardian architecture, new standard tools, streaming/vision support, progress events, and updated API/export docs
- Add DualAgentOrchestrator concept and describe Driver/Guardian agents and BaseToolWrapper
- Document six standard tools including new JsonValidatorTool and expanded descriptions for Filesystem, Http, Shell, Browser, Deno
- Add examples for scoped filesystem with exclusion patterns and line-range reads
- Add token streaming (onToken) and progress events (onProgress) examples and event types
- Document vision support for passing images as base64 and example usage
- Expose additional config options in docs: name, verbose, maxResultChars, maxHistoryMessages, onProgress, onToken, logPrefix
- Document additional result fields: toolCallCount, rejectionCount, toolLog, and error
- Update API signatures in docs: run(task, options?) and registerScopedFilesystemTool(basePath, excludePatterns?)
- Update re-exports to include IFilesystemToolOptions, TDenoPermission, JsonValidatorTool and re-export several smartai types
## 2026-01-20 - 1.3.0 - feat(smartagent)
add JsonValidatorTool and support passing base64-encoded images with task runs (vision-capable models); bump @push.rocks/smartai to ^0.12.0
- Add JsonValidatorTool (validate/format actions) implemented in ts/smartagent.tools.json.ts
- Export JsonValidatorTool from ts/index.ts
- Add ITaskRunOptions interface (images?: string[]) in smartagent.interfaces.ts
- DualAgent.run and Driver.startTask accept optional images and pass them to provider.chat/provider.chatStreaming; assistant responses added to message history
- Bump dependency @push.rocks/smartai from ^0.11.1 to ^0.12.0 in package.json
## 2026-01-20 - 1.2.7 - fix(deps(smartai))
bump @push.rocks/smartai to ^0.11.0
- package.json: @push.rocks/smartai updated from ^0.10.1 to ^0.11.0
- Recommend a patch release since this is a dependency update with no breaking API changes: 1.2.7
## 2026-01-20 - 1.2.6 - fix(deps)
bump @push.rocks/smartai to ^0.10.1
- Updated dependency @push.rocks/smartai from ^0.8.0 to ^0.10.1 in package.json
- No other code changes; dependency-only update
## 2025-12-15 - 1.1.1 - fix(ci)
Update CI/release config and bump devDependencies; enable verbose tests

View File

@@ -5,7 +5,7 @@
"githost": "code.foss.global",
"gitscope": "push.rocks",
"gitrepo": "smartagent",
"description": "an agentic framework built on top of @push.rocks/smartai",
"description": "Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.",
"npmPackagename": "@push.rocks/smartagent",
"license": "MIT",
"projectDomain": "push.rocks"

View File

@@ -1,32 +1,47 @@
{
"name": "@push.rocks/smartagent",
"version": "1.1.1",
"version": "3.0.1",
"private": false,
"description": "an agentic framework built on top of @push.rocks/smartai",
"description": "Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module",
"exports": {
".": {
"import": "./dist_ts/index.js",
"types": "./dist_ts/index.d.ts"
},
"./tools": {
"import": "./dist_ts_tools/index.js",
"types": "./dist_ts_tools/index.d.ts"
},
"./compaction": {
"import": "./dist_ts_compaction/index.js",
"types": "./dist_ts_compaction/index.d.ts"
}
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/ --verbose)",
"build": "(tsbuild --web --allowimplicitany)",
"test": "(tstest test/ --verbose --logfile --timeout 120)",
"build": "(tsbuild tsfolders --allowimplicitany)",
"buildDocs": "(tsdoc)"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^2.6.3",
"@git.zone/tsbuild": "^4.3.0",
"@git.zone/tsbundle": "^2.9.1",
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.1.3",
"@types/node": "^25.0.2"
"@git.zone/tstest": "^3.3.0",
"@push.rocks/qenv": "^6.1.3",
"@types/node": "^25.3.5"
},
"dependencies": {
"@push.rocks/smartai": "^0.8.0",
"@push.rocks/smartbrowser": "^2.0.8",
"@push.rocks/smartdeno": "^1.2.0",
"@push.rocks/smartfs": "^1.2.0",
"@push.rocks/smartai": "^2.0.0",
"@push.rocks/smartfs": "^1.4.0",
"@push.rocks/smartrequest": "^5.0.1",
"@push.rocks/smartshell": "^3.3.0"
"@push.rocks/smartshell": "^3.3.7",
"ai": "^6.0.0",
"zod": "^3.25.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
"repository": {
@@ -39,13 +54,11 @@
"homepage": "https://code.foss.global/push.rocks/smartagent#readme",
"files": [
"ts/**/*",
"ts_web/**/*",
"ts_tools/**/*",
"ts_compaction/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
"readme.md"
],

3158
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,51 @@
# Project Readme Hints
## Overview
`@push.rocks/smartagent` is an agentic framework built on top of `@push.rocks/smartai`. It provides autonomous AI agent capabilities including tool use, multi-step reasoning, and conversation memory.
`@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
- **SmartAgent**: Main class that wraps SmartAi and adds agentic behaviors
- **plugins.ts**: Imports and re-exports smartai
- **index.ts**: Main entry point, exports SmartAgent class and relevant types
## 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).
## Source Layout
```
ts/ → core: runAgent, ToolRegistry, truncateOutput, interfaces
ts_tools/ → built-in tool factories (filesystem, shell, http, json)
ts_compaction/ → compactMessages helper for onContextOverflow
```
## 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`: Provides the underlying multi-modal AI provider interface
- `@push.rocks/smartai` ^2.0.0 — provider registry, `getModel()`, re-exports `tool`, `jsonSchema`
- `ai` ^6.0.0 — Vercel AI SDK v6 (`streamText`, `stepCountIs`, `ModelMessage`, `ToolSet`)
- `zod` ^3.25.0 — tool input schema definitions
- `@push.rocks/smartfs`, `smartshell`, `smartrequest` — tool implementations
## AI SDK v6 Key APIs
- `streamText({ model, messages, tools, stopWhen: stepCountIs(20) })` — agentic loop
- `tool({ description, inputSchema: z.object({...}), execute })` — define tools
- `ModelMessage` — message type (replaces v4's `CoreMessage`)
- `LanguageModelV3` — model type from `@ai-sdk/provider`
- Result is `StreamTextResult` with PromiseLike properties (`await result.text`, etc.)
## Package Exports
- `.` → core (runAgent, ToolRegistry, truncateOutput, re-exports)
- `./tools` → built-in tool factories
- `./compaction` → compactMessages
## Build
- `pnpm build``tsbuild tsfolders --allowimplicitany`
- Cross-folder imports via each folder's `plugins.ts` (tsbuild unpack resolves them)
## Test Structure
- Tests use `@git.zone/tstest/tapbundle`
- Tests must end with `export default tap.start();`
- Tests must end with `export default tap.start()`
- `pnpm test``tstest test/ --verbose`

664
readme.md
View File

@@ -1,367 +1,393 @@
# @push.rocks/smartagent
A dual-agent agentic framework with Driver and Guardian agents for safe, policy-controlled AI task execution.
A lightweight agentic loop built on **Vercel AI SDK v6** via `@push.rocks/smartai`. Register tools, get a model, call `runAgent()` — done. 🚀
## Install
```bash
npm install @push.rocks/smartagent
# or
pnpm install @push.rocks/smartagent
```
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Overview
SmartAgent implements a dual-agent architecture:
`@push.rocks/smartagent` wraps the AI SDK's `streamText` with `stopWhen: stepCountIs(n)` for **parallel multi-step tool execution**. No classes to instantiate, no lifecycle to manage — just one async function:
- **Driver Agent**: Executes tasks, reasons about goals, and proposes tool calls
- **Guardian Agent**: Evaluates tool call proposals against a policy prompt, approving or rejecting with feedback
```typescript
import { runAgent, tool, z } from '@push.rocks/smartagent';
import { getModel } from '@push.rocks/smartai';
This design ensures safe tool use through AI-based policy evaluation rather than rigid programmatic rules.
const model = getModel({
provider: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
apiKey: process.env.ANTHROPIC_TOKEN,
});
const result = await runAgent({
model,
prompt: 'What is 7 + 35?',
system: 'You are a helpful assistant. Use tools when asked.',
tools: {
calculator: tool({
description: 'Perform arithmetic',
inputSchema: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
a: z.number(),
b: z.number(),
}),
execute: async ({ operation, a, b }) => {
const ops = { add: a + b, subtract: a - b, multiply: a * b, divide: a / b };
return String(ops[operation]);
},
}),
},
maxSteps: 10,
});
console.log(result.text); // "7 + 35 = 42"
console.log(result.steps); // number of agentic steps taken
console.log(result.usage); // { promptTokens, completionTokens, totalTokens }
```
## Architecture
```mermaid
flowchart TB
subgraph Input
Task["User Task"]
Policy["Guardian Policy Prompt"]
end
subgraph Orchestrator["DualAgentOrchestrator"]
Driver["Driver Agent<br/><i>Reason + Plan</i>"]
Guardian["Guardian Agent<br/><i>Evaluate against policy</i>"]
Driver -->|"tool call proposal"| Guardian
Guardian -->|"approve / reject + feedback"| Driver
end
subgraph Tools["Standard Tools"]
FS["Filesystem"]
HTTP["HTTP"]
Shell["Shell"]
Browser["Browser"]
Deno["Deno"]
end
Task --> Orchestrator
Policy --> Guardian
Driver -->|"execute<br/>(if approved)"| Tools
Tools -->|"result"| Driver
```
┌─────────────────────────────────────────────────┐
runAgent({ model, prompt, tools, maxSteps }) │
│ │
┌────────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Messages │──▶│ streamText│──▶│ Tools │ │
│ │ (history) │◀──│ (AI SDK) │◀──│ (ToolSet) │ │
└────────────┘ └───────────┘ └───────────┘ │
│ stopWhen: stepCountIs(maxSteps) │
│ + retry with backoff on 429/529/503 │
+ context overflow detection & recovery │
+ tool call repair (case-insensitive matching) │
└─────────────────────────────────────────────────┘
```
## Quick Start
**Key features:**
- 🔄 **Multi-step agentic loop** — the model calls tools, sees results, and continues reasoning until done
-**Parallel tool execution** — multiple tool calls in a single step are executed concurrently
- 🔧 **Auto-retry with backoff** — handles 429/529/503 errors with header-aware retry delays
- 🩹 **Tool call repair** — case-insensitive name matching + invalid tool sink prevents crashes
- 📊 **Token streaming**`onToken` and `onToolCall` callbacks for real-time progress
- 💥 **Context overflow handling** — detects overflow and invokes your `onContextOverflow` callback
## Core API
### `runAgent(options): Promise<IAgentRunResult>`
The single entry point. Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `model` | `LanguageModelV3` | *required* | Model from `@push.rocks/smartai`'s `getModel()` |
| `prompt` | `string` | *required* | The user's task/question |
| `system` | `string` | `undefined` | System prompt |
| `tools` | `ToolSet` | `{}` | Tools the agent can call |
| `maxSteps` | `number` | `20` | Max agentic steps before stopping |
| `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) |
| `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors |
| `onToken` | `(delta: string) => void` | — | Streaming token callback |
| `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked |
| `onContextOverflow` | `(messages) => messages` | — | Handle context overflow (e.g., compact messages) |
### `IAgentRunResult`
```typescript
import { DualAgentOrchestrator } from '@push.rocks/smartagent';
interface IAgentRunResult {
text: string; // Final response text
finishReason: string; // 'stop', 'tool-calls', 'length', etc.
steps: number; // Number of agentic steps taken
messages: ModelMessage[]; // Full conversation for multi-turn
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
```
// Create orchestrator with Guardian policy
const orchestrator = new DualAgentOrchestrator({
openaiToken: 'sk-...',
defaultProvider: 'openai',
guardianPolicyPrompt: `
FILE SYSTEM POLICY:
- ONLY allow reading/writing within /tmp or the current working directory
- REJECT operations on system directories or sensitive files
## Defining Tools 🛠️
SHELL POLICY:
- Allow read-only commands (ls, cat, grep, echo)
- REJECT destructive commands (rm, mv, chmod) without explicit justification
Tools use Vercel AI SDK's `tool()` helper with Zod schemas:
FLAG any attempt to expose secrets or credentials.
`,
```typescript
import { tool, z } from '@push.rocks/smartagent';
const myTool = tool({
description: 'Describe what this tool does',
inputSchema: z.object({
param1: z.string().describe('What this parameter is for'),
param2: z.number().optional(),
}),
execute: async ({ param1, param2 }) => {
// Do work, return a string
return `Result: ${param1}`;
},
});
```
Pass tools as a flat object to `runAgent()`:
```typescript
await runAgent({
model,
prompt: 'Do the thing',
tools: { myTool, anotherTool },
maxSteps: 10,
});
```
## ToolRegistry
A lightweight helper for collecting tools:
```typescript
import { ToolRegistry, tool, z } from '@push.rocks/smartagent';
const registry = new ToolRegistry();
registry.register('random_number', tool({
description: 'Generate a random integer between min and max',
inputSchema: z.object({
min: z.number(),
max: z.number(),
}),
execute: async ({ min, max }) => {
return String(Math.floor(Math.random() * (max - min + 1)) + min);
},
}));
registry.register('is_even', tool({
description: 'Check if a number is even',
inputSchema: z.object({ number: z.number() }),
execute: async ({ number: n }) => n % 2 === 0 ? 'Yes' : 'No',
}));
const result = await runAgent({
model,
prompt: 'Generate a random number and tell me if it is even',
tools: registry.getTools(),
maxSteps: 10,
});
```
## Built-in Tool Factories 🧰
Import from the `@push.rocks/smartagent/tools` subpath:
```typescript
import { filesystemTool, shellTool, httpTool, jsonTool } from '@push.rocks/smartagent/tools';
```
### `filesystemTool(options?)`
Returns: `read_file`, `write_file`, `list_directory`, `delete_file`
```typescript
const tools = filesystemTool({ rootDir: '/home/user/workspace' });
await runAgent({
model,
prompt: 'Create a file called hello.txt with "Hello World"',
tools,
maxSteps: 5,
});
```
Options:
- `rootDir` — restrict all file operations to this directory. Paths outside it throw `Access denied`.
### `shellTool(options?)`
Returns: `run_command`
```typescript
const tools = shellTool({ cwd: '/tmp', allowedCommands: ['ls', 'echo', 'cat'] });
await runAgent({
model,
prompt: 'List all files in /tmp',
tools,
maxSteps: 5,
});
```
Options:
- `cwd` — working directory for commands
- `allowedCommands` — whitelist of allowed commands (if set, others are rejected)
### `httpTool()`
Returns: `http_get`, `http_post`
```typescript
const tools = httpTool();
await runAgent({
model,
prompt: 'Fetch the data from https://api.example.com/status',
tools,
maxSteps: 5,
});
```
### `jsonTool()`
Returns: `json_validate`, `json_transform`
```typescript
const tools = jsonTool();
// Direct usage:
const result = await tools.json_validate.execute({
jsonString: '{"name":"test","value":42}',
requiredFields: ['name', 'value'],
});
// → "Valid JSON: object with 2 keys"
```
## Streaming & Callbacks 🎥
Monitor the agent in real-time:
```typescript
const result = await runAgent({
model,
prompt: 'Analyze this data...',
tools,
maxSteps: 10,
// Token-by-token streaming
onToken: (delta) => process.stdout.write(delta),
// Tool call notifications
onToolCall: (toolName) => console.log(`\n🔧 Calling: ${toolName}`),
});
```
## Context Overflow Handling 💥
For long-running agents that might exceed the model's context window, use the compaction subpath:
```typescript
import { runAgent } from '@push.rocks/smartagent';
import { compactMessages } from '@push.rocks/smartagent/compaction';
const result = await runAgent({
model,
prompt: 'Process all 500 files...',
tools,
maxSteps: 100,
onContextOverflow: async (messages) => {
// Summarize the conversation to free up context space
return await compactMessages(model, messages);
},
});
```
## Output Truncation ✂️
Prevent large tool outputs from consuming too much context:
```typescript
import { truncateOutput } from '@push.rocks/smartagent';
const { content, truncated, notice } = truncateOutput(hugeOutput, {
maxLines: 2000, // default
maxBytes: 50_000, // default
});
```
The built-in tool factories use `truncateOutput` internally.
## Multi-Turn Conversations 💬
Pass the returned `messages` back for multi-turn interactions:
```typescript
// First turn
const turn1 = await runAgent({
model,
prompt: 'Create a project structure',
tools,
maxSteps: 10,
});
// Register standard tools
orchestrator.registerStandardTools();
// Start the orchestrator (initializes all tools)
await orchestrator.start();
// Run a task
const result = await orchestrator.run('List all TypeScript files in the current directory');
console.log('Success:', result.success);
console.log('Result:', result.result);
console.log('Iterations:', result.iterations);
// Cleanup
await orchestrator.stop();
// Second turn — continues the conversation
const turn2 = await runAgent({
model,
prompt: 'Now add a README to the project',
tools,
maxSteps: 10,
messages: turn1.messages, // pass history
});
```
## Standard Tools
## Exports
### FilesystemTool
File and directory operations using `@push.rocks/smartfs`.
### Main (`@push.rocks/smartagent`)
**Actions**: `read`, `write`, `append`, `list`, `delete`, `exists`, `stat`, `copy`, `move`, `mkdir`
| Export | Type | Description |
|--------|------|-------------|
| `runAgent` | function | Core agentic loop |
| `ToolRegistry` | class | Tool collection helper |
| `truncateOutput` | function | Output truncation utility |
| `ContextOverflowError` | class | Error type for context overflow |
| `tool` | function | Re-exported from `@push.rocks/smartai` |
| `z` | object | Re-exported Zod for schema definitions |
| `stepCountIs` | function | Re-exported from AI SDK |
| `jsonSchema` | function | Re-exported from `@push.rocks/smartai` |
```typescript
// Example tool call by Driver
<tool_call>
<tool>filesystem</tool>
<action>read</action>
<params>{"path": "/tmp/config.json"}</params>
<reasoning>Need to read the configuration file to understand the settings</reasoning>
</tool_call>
```
### Tools (`@push.rocks/smartagent/tools`)
### HttpTool
HTTP requests using `@push.rocks/smartrequest`.
| Export | Type | Description |
|--------|------|-------------|
| `filesystemTool` | factory | File operations (read, write, list, delete) |
| `shellTool` | factory | Shell command execution |
| `httpTool` | factory | HTTP GET/POST requests |
| `jsonTool` | factory | JSON validation and transformation |
**Actions**: `get`, `post`, `put`, `patch`, `delete`
### Compaction (`@push.rocks/smartagent/compaction`)
```typescript
<tool_call>
<tool>http</tool>
<action>get</action>
<params>{"url": "https://api.example.com/data"}</params>
<reasoning>Fetching data from the API endpoint</reasoning>
</tool_call>
```
| Export | Type | Description |
|--------|------|-------------|
| `compactMessages` | function | Summarize message history to free context |
### ShellTool
Secure shell command execution using `@push.rocks/smartshell` with `execSpawn` (no shell injection).
## Dependencies
**Actions**: `execute`, `which`
```typescript
<tool_call>
<tool>shell</tool>
<action>execute</action>
<params>{"command": "ls", "args": ["-la", "/tmp"]}</params>
<reasoning>Listing directory contents to find relevant files</reasoning>
</tool_call>
```
### BrowserTool
Web page interaction using `@push.rocks/smartbrowser` (Puppeteer-based).
**Actions**: `screenshot`, `pdf`, `evaluate`, `getPageContent`
```typescript
<tool_call>
<tool>browser</tool>
<action>getPageContent</action>
<params>{"url": "https://example.com"}</params>
<reasoning>Extracting text content from the webpage</reasoning>
</tool_call>
```
### DenoTool
Execute TypeScript/JavaScript code in a sandboxed Deno environment using `@push.rocks/smartdeno`.
**Actions**: `execute`, `executeWithResult`
**Permissions**: `all`, `env`, `ffi`, `hrtime`, `net`, `read`, `run`, `sys`, `write`
By default, code runs fully sandboxed with no permissions. Permissions must be explicitly requested.
```typescript
// Simple code execution
<tool_call>
<tool>deno</tool>
<action>execute</action>
<params>{"code": "console.log('Hello from Deno!')"}</params>
<reasoning>Running a simple script to verify the environment</reasoning>
</tool_call>
// Code with network permission
<tool_call>
<tool>deno</tool>
<action>execute</action>
<params>{
"code": "const resp = await fetch('https://api.example.com/data'); console.log(await resp.json());",
"permissions": ["net"]
}</params>
<reasoning>Fetching data from API using Deno's fetch</reasoning>
</tool_call>
// Execute and parse JSON result
<tool_call>
<tool>deno</tool>
<action>executeWithResult</action>
<params>{
"code": "const result = { sum: 2 + 2, date: new Date().toISOString() }; console.log(JSON.stringify(result));"
}</params>
<reasoning>Computing values and returning structured data</reasoning>
</tool_call>
```
## Guardian Policy Examples
### Strict Security Policy
```typescript
const securityPolicy = `
SECURITY POLICY:
1. REJECT any file operations outside /home/user/workspace
2. REJECT any shell commands that could modify system state
3. REJECT any HTTP requests to internal/private IP ranges
4. REJECT any attempts to read environment variables or credentials
5. FLAG and REJECT obfuscated code execution
When rejecting, always explain:
- What policy was violated
- What would be a safer alternative
`;
```
### Development Environment Policy
```typescript
const devPolicy = `
DEVELOPMENT POLICY:
- Allow file operations only within the project directory
- Allow npm/pnpm commands for package management
- Allow git commands for version control
- Allow HTTP requests to public APIs only
- REJECT direct database modifications
- REJECT commands that could affect other users
Always verify:
- File paths are relative or within project bounds
- Commands don't have dangerous flags (--force, -rf)
`;
```
### Deno Code Execution Policy
```typescript
const denoPolicy = `
DENO CODE EXECUTION POLICY:
- ONLY allow 'read' permission for files within the workspace
- REJECT 'all' permission unless explicitly justified for the task
- REJECT 'run' permission (subprocess execution) without specific justification
- REJECT code that attempts to:
- Access credentials or environment secrets (even with 'env' permission)
- Make network requests to internal/private IP ranges
- Write to system directories
- FLAG obfuscated or encoded code (base64, eval with dynamic strings)
- Prefer sandboxed execution (no permissions) when possible
When evaluating code:
- Review the actual code content, not just permissions
- Consider what data the code could exfiltrate
- Verify network endpoints are legitimate public APIs
`;
```
## Configuration Options
```typescript
interface IDualAgentOptions {
// Provider tokens (from @push.rocks/smartai)
openaiToken?: string;
anthropicToken?: string;
perplexityToken?: string;
groqToken?: string;
xaiToken?: string;
// Provider selection
defaultProvider?: TProvider; // For both Driver and Guardian
guardianProvider?: TProvider; // Optional: separate provider for Guardian
// Agent configuration
driverSystemMessage?: string; // Custom system message for Driver
guardianPolicyPrompt: string; // REQUIRED: Policy for Guardian to enforce
// Limits
maxIterations?: number; // Max task iterations (default: 20)
maxConsecutiveRejections?: number; // Abort after N rejections (default: 3)
}
```
## Result Interface
```typescript
interface IDualAgentRunResult {
success: boolean; // Whether task completed successfully
completed: boolean; // Task completion status
result: string; // Final result or response
iterations: number; // Number of iterations taken
history: IAgentMessage[]; // Full conversation history
status: TDualAgentRunStatus; // 'completed' | 'max_iterations_reached' | etc.
}
```
## Custom Tools
Create custom tools by extending `BaseToolWrapper`:
```typescript
import { BaseToolWrapper, IToolAction, IToolExecutionResult } from '@push.rocks/smartagent';
class MyCustomTool extends BaseToolWrapper {
public name = 'custom';
public description = 'My custom tool for specific operations';
public actions: IToolAction[] = [
{
name: 'myAction',
description: 'Performs a custom action',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Input for the action' },
},
required: ['input'],
},
},
];
public async initialize(): Promise<void> {
this.isInitialized = true;
}
public async cleanup(): Promise<void> {
this.isInitialized = false;
}
public async execute(action: string, params: Record<string, unknown>): Promise<IToolExecutionResult> {
this.validateAction(action);
this.ensureInitialized();
if (action === 'myAction') {
return {
success: true,
result: { processed: params.input },
};
}
return { success: false, error: 'Unknown action' };
}
public getCallSummary(action: string, params: Record<string, unknown>): string {
return `Custom action "${action}" with input "${params.input}"`;
}
}
// Register custom tool
orchestrator.registerTool(new MyCustomTool());
```
## Supported Providers
| Provider | Driver | Guardian |
|----------|:------:|:--------:|
| OpenAI | Yes | Yes |
| Anthropic | Yes | Yes |
| Perplexity | Yes | Yes |
| Groq | Yes | Yes |
| Ollama | Yes | Yes |
| XAI | Yes | Yes |
- **[`@push.rocks/smartai`](https://code.foss.global/push.rocks/smartai)** — Provider registry, `getModel()`, re-exports `tool`/`jsonSchema`
- **[`ai`](https://www.npmjs.com/package/ai)** v6 — Vercel AI SDK (`streamText`, `stepCountIs`, `ModelMessage`)
- **[`zod`](https://www.npmjs.com/package/zod)** — Tool input schema definitions
- **[`@push.rocks/smartfs`](https://code.foss.global/push.rocks/smartfs)** — Filesystem tool implementation
- **[`@push.rocks/smartshell`](https://code.foss.global/push.rocks/smartshell)** — Shell tool implementation
- **[`@push.rocks/smartrequest`](https://code.foss.global/push.rocks/smartrequest)** — HTTP tool implementation
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**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.
### Trademarks
This 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.
This 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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By 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.

239
test/test.agent-e2e.ts Normal file
View File

@@ -0,0 +1,239 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as qenv from '@push.rocks/qenv';
import * as path from 'path';
import * as fs from 'fs';
import { runAgent, tool, z, ToolRegistry } from '../ts/index.js';
import { filesystemTool, shellTool } from '../ts_tools/index.js';
const testQenv = new qenv.Qenv('./', './.nogit/');
let model: any;
const workDir = '/tmp/smartagent-e2e-' + Date.now();
tap.test('setup: create model and workspace', async () => {
const apiKey = await testQenv.getEnvVarOnDemand('ANTHROPIC_TOKEN');
if (!apiKey) {
console.log('ANTHROPIC_TOKEN not set — skipping all E2E tests');
process.exit(0);
}
const { getModel } = await import('@push.rocks/smartai');
model = getModel({
provider: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
apiKey,
});
fs.mkdirSync(workDir, { recursive: true });
console.log(` Workspace: ${workDir}`);
});
// ============================================================
// Test 1: Simple tool call
// ============================================================
tap.test('agent should call a single tool and incorporate the result', async () => {
let toolCalled = false;
const result = await runAgent({
model,
prompt: 'What is the current time? Use the get_time tool.',
system: 'You are a helpful assistant. Use tools when asked.',
tools: {
get_time: tool({
description: 'Returns the current ISO timestamp',
inputSchema: z.object({}),
execute: async () => {
toolCalled = true;
return new Date().toISOString();
},
}),
},
maxSteps: 5,
});
console.log(` Response: ${result.text.substring(0, 150)}`);
console.log(` Steps: ${result.steps}, Tokens: ${result.usage.totalTokens}`);
expect(toolCalled).toBeTrue();
expect(result.text).toBeTruthy();
expect(result.usage.totalTokens).toBeGreaterThan(0);
});
// ============================================================
// Test 2: Multiple tools — agent chooses which to use
// ============================================================
tap.test('agent should pick the right tool from multiple options', async () => {
const callLog: string[] = [];
const result = await runAgent({
model,
prompt: 'Add 7 and 35 using the calculator tool.',
system: 'You are a helpful assistant. Use the appropriate tool to answer.',
tools: {
calculator: tool({
description: 'Perform arithmetic. Supports add, subtract, multiply, divide.',
inputSchema: z.object({
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
a: z.number(),
b: z.number(),
}),
execute: async ({ operation, a, b }: { operation: string; a: number; b: number }) => {
callLog.push(`calculator:${operation}(${a}, ${b})`);
switch (operation) {
case 'add': return String(a + b);
case 'subtract': return String(a - b);
case 'multiply': return String(a * b);
case 'divide': return b !== 0 ? String(a / b) : 'Error: division by zero';
default: return 'Unknown operation';
}
},
}),
get_weather: tool({
description: 'Get current weather for a city',
inputSchema: z.object({ city: z.string() }),
execute: async () => {
callLog.push('get_weather');
return 'Sunny, 22°C';
},
}),
},
maxSteps: 5,
});
console.log(` Tool calls: ${callLog.join(', ')}`);
console.log(` Response: ${result.text.substring(0, 150)}`);
expect(callLog.some((c) => c.startsWith('calculator:add'))).toBeTrue();
expect(callLog).not.toContain('get_weather');
expect(result.text).toInclude('42');
});
// ============================================================
// Test 3: Multi-step — agent uses filesystem tools
// ============================================================
tap.test('agent should use filesystem tools for a multi-step task', async () => {
const fsTools = filesystemTool({ rootDir: workDir });
const result = await runAgent({
model,
prompt: `Create a file called "greeting.txt" in ${workDir} with the content "Hello from smartagent!". Then read it back and tell me what it says.`,
system: 'You are a helpful assistant that works with files. Use the provided tools.',
tools: fsTools,
maxSteps: 10,
});
console.log(` Steps: ${result.steps}`);
console.log(` Response: ${result.text.substring(0, 200)}`);
// Verify the file was actually created
const filePath = path.join(workDir, 'greeting.txt');
expect(fs.existsSync(filePath)).toBeTrue();
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toInclude('Hello from smartagent');
expect(result.steps).toBeGreaterThanOrEqual(2);
});
// ============================================================
// Test 4: ToolRegistry usage
// ============================================================
tap.test('agent should work with ToolRegistry', async () => {
const registry = new ToolRegistry();
registry.register('random_number', tool({
description: 'Generate a random integer between min and max (inclusive)',
inputSchema: z.object({
min: z.number().describe('Minimum value'),
max: z.number().describe('Maximum value'),
}),
execute: async ({ min, max }: { min: number; max: number }) => {
const value = Math.floor(Math.random() * (max - min + 1)) + min;
return String(value);
},
}));
registry.register('is_even', tool({
description: 'Check if a number is even',
inputSchema: z.object({ number: z.number() }),
execute: async ({ number: n }: { number: number }) => {
return n % 2 === 0 ? 'Yes, it is even' : 'No, it is odd';
},
}));
const result = await runAgent({
model,
prompt: 'Generate a random number between 1 and 100, then check if it is even or odd. Tell me both the number and whether it is even.',
system: 'You are a helpful assistant. Use tools step by step.',
tools: registry.getTools(),
maxSteps: 10,
});
console.log(` Response: ${result.text.substring(0, 200)}`);
expect(result.text).toBeTruthy();
expect(result.steps).toBeGreaterThanOrEqual(2);
});
// ============================================================
// Test 5: Streaming callbacks
// ============================================================
tap.test('agent should fire onToken and onToolCall callbacks', async () => {
const tokens: string[] = [];
const toolCalls: string[] = [];
const result = await runAgent({
model,
prompt: 'Use the echo tool to echo "test123".',
system: 'You are a helpful assistant. Use tools when asked.',
tools: {
echo: tool({
description: 'Echo back the provided text',
inputSchema: z.object({ text: z.string() }),
execute: async ({ text }: { text: string }) => text,
}),
},
maxSteps: 5,
onToken: (delta) => tokens.push(delta),
onToolCall: (name) => toolCalls.push(name),
});
console.log(` Streamed ${tokens.length} token chunks`);
console.log(` Tool calls observed: ${toolCalls.join(', ')}`);
expect(tokens.length).toBeGreaterThan(0);
expect(toolCalls).toContain('echo');
expect(result.text).toInclude('test123');
});
// ============================================================
// Test 6: Shell tool integration
// ============================================================
tap.test('agent should use shell tool to run a command', async () => {
const tools = shellTool();
const result = await runAgent({
model,
prompt: `Run the command "echo hello_smartagent" and tell me what it outputs.`,
system: 'You are a helpful assistant that can run shell commands.',
tools,
maxSteps: 5,
});
console.log(` Response: ${result.text.substring(0, 200)}`);
expect(result.text).toInclude('hello_smartagent');
});
// ============================================================
// Cleanup
// ============================================================
tap.test('cleanup: remove workspace', async () => {
fs.rmSync(workDir, { recursive: true, force: true });
console.log(` Cleaned up ${workDir}`);
});
export default tap.start();

View File

@@ -1,150 +1,188 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartagent from '../ts/index.js';
import { filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
import { compactMessages } from '../ts_compaction/index.js';
// Test exports
tap.test('should export DualAgentOrchestrator class', async () => {
expect(smartagent.DualAgentOrchestrator).toBeTypeOf('function');
// ============================================================
// Core exports
// ============================================================
tap.test('should export runAgent function', async () => {
expect(smartagent.runAgent).toBeTypeOf('function');
});
tap.test('should export DriverAgent class', async () => {
expect(smartagent.DriverAgent).toBeTypeOf('function');
tap.test('should export ToolRegistry class', async () => {
expect(smartagent.ToolRegistry).toBeTypeOf('function');
});
tap.test('should export GuardianAgent class', async () => {
expect(smartagent.GuardianAgent).toBeTypeOf('function');
tap.test('should export ContextOverflowError class', async () => {
expect(smartagent.ContextOverflowError).toBeTypeOf('function');
});
tap.test('should export BaseToolWrapper class', async () => {
expect(smartagent.BaseToolWrapper).toBeTypeOf('function');
tap.test('should export truncateOutput function', async () => {
expect(smartagent.truncateOutput).toBeTypeOf('function');
});
// Test standard tools exports
tap.test('should export FilesystemTool class', async () => {
expect(smartagent.FilesystemTool).toBeTypeOf('function');
tap.test('should re-export tool helper', async () => {
expect(smartagent.tool).toBeTypeOf('function');
});
tap.test('should export HttpTool class', async () => {
expect(smartagent.HttpTool).toBeTypeOf('function');
tap.test('should re-export z (zod)', async () => {
expect(smartagent.z).toBeTruthy();
});
tap.test('should export ShellTool class', async () => {
expect(smartagent.ShellTool).toBeTypeOf('function');
tap.test('should re-export stepCountIs', async () => {
expect(smartagent.stepCountIs).toBeTypeOf('function');
});
tap.test('should export BrowserTool class', async () => {
expect(smartagent.BrowserTool).toBeTypeOf('function');
});
// ============================================================
// ToolRegistry
// ============================================================
tap.test('should export DenoTool class', async () => {
expect(smartagent.DenoTool).toBeTypeOf('function');
});
// Test tool instantiation
tap.test('should be able to instantiate FilesystemTool', async () => {
const fsTool = new smartagent.FilesystemTool();
expect(fsTool.name).toEqual('filesystem');
expect(fsTool.actions).toBeTypeOf('object');
expect(fsTool.actions.length).toBeGreaterThan(0);
});
tap.test('should be able to instantiate HttpTool', async () => {
const httpTool = new smartagent.HttpTool();
expect(httpTool.name).toEqual('http');
expect(httpTool.actions).toBeTypeOf('object');
});
tap.test('should be able to instantiate ShellTool', async () => {
const shellTool = new smartagent.ShellTool();
expect(shellTool.name).toEqual('shell');
expect(shellTool.actions).toBeTypeOf('object');
});
tap.test('should be able to instantiate BrowserTool', async () => {
const browserTool = new smartagent.BrowserTool();
expect(browserTool.name).toEqual('browser');
expect(browserTool.actions).toBeTypeOf('object');
});
tap.test('should be able to instantiate DenoTool', async () => {
const denoTool = new smartagent.DenoTool();
expect(denoTool.name).toEqual('deno');
expect(denoTool.actions).toBeTypeOf('object');
});
// Test tool descriptions
tap.test('FilesystemTool should have required actions', async () => {
const fsTool = new smartagent.FilesystemTool();
const actionNames = fsTool.actions.map((a) => a.name);
expect(actionNames).toContain('read');
expect(actionNames).toContain('write');
expect(actionNames).toContain('list');
expect(actionNames).toContain('delete');
expect(actionNames).toContain('exists');
});
tap.test('HttpTool should have required actions', async () => {
const httpTool = new smartagent.HttpTool();
const actionNames = httpTool.actions.map((a) => a.name);
expect(actionNames).toContain('get');
expect(actionNames).toContain('post');
expect(actionNames).toContain('put');
expect(actionNames).toContain('delete');
});
tap.test('ShellTool should have required actions', async () => {
const shellTool = new smartagent.ShellTool();
const actionNames = shellTool.actions.map((a) => a.name);
expect(actionNames).toContain('execute');
expect(actionNames).toContain('which');
});
tap.test('BrowserTool should have required actions', async () => {
const browserTool = new smartagent.BrowserTool();
const actionNames = browserTool.actions.map((a) => a.name);
expect(actionNames).toContain('screenshot');
expect(actionNames).toContain('pdf');
expect(actionNames).toContain('evaluate');
expect(actionNames).toContain('getPageContent');
});
tap.test('DenoTool should have required actions', async () => {
const denoTool = new smartagent.DenoTool();
const actionNames = denoTool.actions.map((a) => a.name);
expect(actionNames).toContain('execute');
expect(actionNames).toContain('executeWithResult');
});
// Test getCallSummary
tap.test('FilesystemTool should generate call summaries', async () => {
const fsTool = new smartagent.FilesystemTool();
const summary = fsTool.getCallSummary('read', { path: '/tmp/test.txt' });
expect(summary).toBeTypeOf('string');
expect(summary).toInclude('/tmp/test.txt');
});
tap.test('HttpTool should generate call summaries', async () => {
const httpTool = new smartagent.HttpTool();
const summary = httpTool.getCallSummary('get', { url: 'https://example.com' });
expect(summary).toBeTypeOf('string');
expect(summary).toInclude('example.com');
});
tap.test('DenoTool should generate call summaries', async () => {
const denoTool = new smartagent.DenoTool();
const summary = denoTool.getCallSummary('execute', { code: 'console.log("hello");' });
expect(summary).toBeTypeOf('string');
expect(summary).toInclude('sandboxed');
});
tap.test('DenoTool should show permissions in call summary', async () => {
const denoTool = new smartagent.DenoTool();
const summary = denoTool.getCallSummary('execute', {
code: 'console.log("hello");',
permissions: ['net', 'read']
tap.test('ToolRegistry should register and return tools', async () => {
const registry = new smartagent.ToolRegistry();
const echoTool = smartagent.tool({
description: 'Echo tool',
inputSchema: smartagent.z.object({ text: smartagent.z.string() }),
execute: async ({ text }: { text: string }) => text,
});
expect(summary).toBeTypeOf('string');
expect(summary).toInclude('permissions');
expect(summary).toInclude('net');
registry.register('echo', echoTool);
const tools = registry.getTools();
expect(Object.keys(tools)).toContain('echo');
});
// ============================================================
// Truncation
// ============================================================
tap.test('truncateOutput should not truncate short strings', async () => {
const result = truncateOutput('hello world');
expect(result.truncated).toBeFalse();
expect(result.content).toEqual('hello world');
});
tap.test('truncateOutput should truncate strings over maxLines', async () => {
const lines = Array.from({ length: 3000 }, (_, i) => `line ${i}`).join('\n');
const result = truncateOutput(lines, { maxLines: 100 });
expect(result.truncated).toBeTrue();
expect(result.notice).toBeTruthy();
expect(result.content).toInclude('[Output truncated');
});
tap.test('truncateOutput should truncate strings over maxBytes', async () => {
const big = 'x'.repeat(100_000);
const result = truncateOutput(big, { maxBytes: 1000 });
expect(result.truncated).toBeTrue();
});
// ============================================================
// Tool factories
// ============================================================
tap.test('filesystemTool returns expected tool names', async () => {
const tools = filesystemTool();
const names = Object.keys(tools);
expect(names).toContain('read_file');
expect(names).toContain('write_file');
expect(names).toContain('list_directory');
expect(names).toContain('delete_file');
});
tap.test('shellTool returns expected tool names', async () => {
const tools = shellTool();
const names = Object.keys(tools);
expect(names).toContain('run_command');
});
tap.test('httpTool returns expected tool names', async () => {
const tools = httpTool();
const names = Object.keys(tools);
expect(names).toContain('http_get');
expect(names).toContain('http_post');
});
tap.test('jsonTool returns expected tool names', async () => {
const tools = jsonTool();
const names = Object.keys(tools);
expect(names).toContain('json_validate');
expect(names).toContain('json_transform');
});
tap.test('json_validate tool should validate valid JSON', async () => {
const tools = jsonTool();
const result = await (tools.json_validate as any).execute({
jsonString: '{"name":"test","value":42}',
});
expect(result).toInclude('Valid JSON');
});
tap.test('json_validate tool should detect invalid JSON', async () => {
const tools = jsonTool();
const result = await (tools.json_validate as any).execute({
jsonString: '{invalid json',
});
expect(result).toInclude('Invalid JSON');
});
tap.test('json_validate tool should check required fields', async () => {
const tools = jsonTool();
const result = await (tools.json_validate as any).execute({
jsonString: '{"name":"test"}',
requiredFields: ['name', 'missing_field'],
});
expect(result).toInclude('missing_field');
});
tap.test('json_transform tool should pretty-print JSON', async () => {
const tools = jsonTool();
const result = await (tools.json_transform as any).execute({
jsonString: '{"a":1,"b":2}',
});
expect(result).toInclude(' "a": 1');
});
// ============================================================
// Compaction export
// ============================================================
tap.test('compactMessages should be a function', async () => {
expect(compactMessages).toBeTypeOf('function');
});
// ============================================================
// Filesystem tool read/write round-trip
// ============================================================
tap.test('filesystem tool should write and read a file', async () => {
const tmpDir = '/tmp/smartagent-test-' + Date.now();
const tools = filesystemTool({ rootDir: tmpDir });
await (tools.write_file as any).execute({
path: tmpDir + '/hello.txt',
content: 'Hello, world!',
});
const content = await (tools.read_file as any).execute({
path: tmpDir + '/hello.txt',
});
expect(content).toInclude('Hello, world!');
// Cleanup
await (tools.delete_file as any).execute({
path: tmpDir + '/hello.txt',
});
});
tap.test('filesystem tool should enforce rootDir restriction', async () => {
const tools = filesystemTool({ rootDir: '/tmp/restricted' });
let threw = false;
try {
await (tools.read_file as any).execute({ path: '/etc/passwd' });
} catch (e) {
threw = true;
expect((e as Error).message).toInclude('Access denied');
}
expect(threw).toBeTrue();
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartagent',
version: '1.1.1',
description: 'an agentic framework built on top of @push.rocks/smartai'
version: '3.0.1',
description: 'Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.'
}

View File

@@ -1,30 +1,11 @@
import * as plugins from './plugins.js';
export { runAgent } from './smartagent.classes.agent.js';
export { ToolRegistry } from './smartagent.classes.toolregistry.js';
export { truncateOutput } from './smartagent.utils.truncation.js';
export type { ITruncateResult } from './smartagent.utils.truncation.js';
export { ContextOverflowError } from './smartagent.interfaces.js';
export type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
// Export the dual-agent orchestrator (main entry point)
export { DualAgentOrchestrator } from './smartagent.classes.dualagent.js';
// Export individual agents
export { DriverAgent } from './smartagent.classes.driveragent.js';
export { GuardianAgent } from './smartagent.classes.guardianagent.js';
// Export base tool class for custom tool creation
export { BaseToolWrapper } from './smartagent.tools.base.js';
// Export standard tools
export { FilesystemTool } 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 all interfaces
export * from './smartagent.interfaces.js';
// Re-export useful types from smartai
export {
type ISmartAiOptions,
type TProvider,
type ChatMessage,
type ChatOptions,
type ChatResponse,
} from '@push.rocks/smartai';
// Re-export tool() and z so consumers can define tools without extra imports
export { tool, jsonSchema } from '@push.rocks/smartai';
export { z } from 'zod';
export { stepCountIs } from 'ai';

View File

@@ -1,16 +1,27 @@
// @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';
// node native
import * as path from 'path';
export {
smartai,
smartdeno,
smartfs,
smartrequest,
smartbrowser,
smartshell,
};
export { path };
// ai-sdk core
import { streamText, generateText, stepCountIs } from 'ai';
export { streamText, generateText, stepCountIs };
export type {
ModelMessage,
ToolSet,
StreamTextResult,
} from 'ai';
// @push.rocks/smartai
import { tool, jsonSchema } from '@push.rocks/smartai';
export { tool, jsonSchema };
export type { LanguageModelV3 } from '@push.rocks/smartai';
// zod
import { z } from 'zod';
export { z };

View File

@@ -0,0 +1,198 @@
// Retry backoff and context overflow logic derived from opencode (MIT) — https://github.com/sst/opencode
import * as plugins from './plugins.js';
import type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
import { ContextOverflowError } from './smartagent.interfaces.js';
// Retry constants
const RETRY_INITIAL_DELAY = 2000;
const RETRY_BACKOFF_FACTOR = 2;
const RETRY_MAX_DELAY = 30_000;
const MAX_RETRY_ATTEMPTS = 8;
function retryDelay(attempt: number, headers?: Record<string, string>): number {
if (headers) {
const ms = headers['retry-after-ms'];
if (ms) {
const n = parseFloat(ms);
if (!isNaN(n)) return n;
}
const after = headers['retry-after'];
if (after) {
const secs = parseFloat(after);
if (!isNaN(secs)) return Math.ceil(secs * 1000);
const date = Date.parse(after) - Date.now();
if (!isNaN(date) && date > 0) return Math.ceil(date);
}
}
return Math.min(
RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1),
RETRY_MAX_DELAY,
);
}
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
const t = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(t);
reject(new DOMException('Aborted', 'AbortError'));
},
{ once: true },
);
});
}
function isRetryableError(err: unknown): boolean {
const status = (err as any)?.status ?? (err as any)?.statusCode;
if (status === 429 || status === 529 || status === 503) return true;
if (err instanceof Error) {
const msg = err.message.toLowerCase();
if (msg.includes('rate limit') || msg.includes('overloaded') || msg.includes('too many requests')) {
return true;
}
}
return false;
}
function isContextOverflow(err: unknown): boolean {
if (err instanceof Error) {
const msg = err.message.toLowerCase();
return (
msg.includes('context_length_exceeded') ||
msg.includes('context window') ||
msg.includes('maximum context length') ||
msg.includes('too many tokens') ||
msg.includes('input is too long') ||
(err as any)?.name === 'AI_ContextWindowExceededError'
);
}
return false;
}
export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResult> {
let stepCount = 0;
let attempt = 0;
let totalInput = 0;
let totalOutput = 0;
const tools = options.tools ?? {};
// Add a no-op sink for repaired-but-unrecognised tool calls
const allTools: plugins.ToolSet = {
...tools,
invalid: plugins.tool({
description: 'Sink for unrecognised tool calls — returns an error message to the model',
inputSchema: plugins.z.object({
tool: plugins.z.string(),
error: plugins.z.string(),
}),
execute: async ({ tool, error }: { tool: string; error: string }) =>
`Unknown tool "${tool}": ${error}`,
}),
};
// Build messages — streamText requires either prompt OR messages, not both
let messages: plugins.ModelMessage[] = options.messages
? [...options.messages, { role: 'user' as const, content: options.prompt }]
: [{ role: 'user' as const, content: options.prompt }];
while (true) {
try {
const result = plugins.streamText({
model: options.model,
system: options.system,
messages,
tools: allTools,
stopWhen: plugins.stepCountIs(options.maxSteps ?? 20),
maxRetries: 0, // handled manually below
abortSignal: options.abort,
experimental_repairToolCall: async ({ toolCall, tools: availableTools, error }) => {
const lower = toolCall.toolName.toLowerCase();
if (lower !== toolCall.toolName && (availableTools as any)[lower]) {
return { ...toolCall, toolName: lower };
}
return {
...toolCall,
toolName: 'invalid',
args: JSON.stringify({
tool: toolCall.toolName,
error: String(error),
}),
};
},
onChunk: ({ chunk }) => {
if (chunk.type === 'text-delta' && options.onToken) {
options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? '');
}
},
experimental_onToolCallStart: options.onToolCall
? ({ toolCall }) => {
options.onToolCall!(toolCall.toolName, (toolCall as any).input ?? (toolCall as any).args);
}
: undefined,
experimental_onToolCallFinish: options.onToolResult
? ({ toolCall }) => {
options.onToolResult!(toolCall.toolName, (toolCall as any).result);
}
: undefined,
onStepFinish: ({ usage }) => {
stepCount++;
totalInput += usage?.inputTokens ?? 0;
totalOutput += usage?.outputTokens ?? 0;
},
});
// Consume the stream and collect results
const text = await result.text;
const finishReason = await result.finishReason;
const responseData = await result.response;
attempt = 0; // reset on success
return {
text,
messages: responseData.messages as plugins.ModelMessage[],
steps: stepCount,
finishReason,
usage: {
inputTokens: totalInput,
outputTokens: totalOutput,
totalTokens: totalInput + totalOutput,
},
};
} catch (err: unknown) {
// Abort — don't retry
if (err instanceof DOMException && err.name === 'AbortError') throw err;
// Rate limit / overload — retry with backoff
if (isRetryableError(err) && attempt < MAX_RETRY_ATTEMPTS) {
attempt++;
const headers = (err as any)?.responseHeaders ?? (err as any)?.headers;
const delay = retryDelay(attempt, headers);
await sleep(delay, options.abort);
continue;
}
// Context overflow — compact and retry if handler provided
if (isContextOverflow(err)) {
if (!options.onContextOverflow) throw new ContextOverflowError();
messages = await options.onContextOverflow(messages);
continue;
}
throw err;
}
}
}

View File

@@ -1,321 +0,0 @@
import * as plugins from './plugins.js';
import * as interfaces from './smartagent.interfaces.js';
import type { BaseToolWrapper } from './smartagent.tools.base.js';
/**
* 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 messageHistory: plugins.smartai.ChatMessage[] = [];
private tools: Map<string, BaseToolWrapper> = new Map();
constructor(
provider: plugins.smartai.MultiModalModel,
systemMessage?: string
) {
this.provider = provider;
this.systemMessage = systemMessage || this.getDefaultSystemMessage();
}
/**
* 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
*/
public async startTask(task: string): Promise<interfaces.IAgentMessage> {
// Reset message history
this.messageHistory = [];
// Build the user message
const 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.`;
// Add to history
this.messageHistory.push({
role: 'user',
content: userMessage,
});
// Build tool descriptions for the system message
const toolDescriptions = this.buildToolDescriptions();
const fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
// Get response from provider
const response = await this.provider.chat({
systemMessage: fullSystemMessage,
userMessage: userMessage,
messageHistory: [],
});
// Add assistant response to history
this.messageHistory.push({
role: 'assistant',
content: response.message,
});
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 tool descriptions for the system message
const toolDescriptions = this.buildToolDescriptions();
const fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
// Get response from provider (pass all but last user message as history)
const historyForChat = this.messageHistory.slice(0, -1);
const response = await this.provider.chat({
systemMessage: fullSystemMessage,
userMessage: message,
messageHistory: historyForChat,
});
// Add assistant response to history
this.messageHistory.push({
role: 'assistant',
content: response.message,
});
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.
## Tool Usage Format
When you need to use a tool, output a tool call proposal in this format:
<tool_call>
<tool>tool_name</tool>
<action>action_name</action>
<params>
{"param1": "value1", "param2": "value2"}
</params>
<reasoning>Brief explanation of why this action is needed</reasoning>
</tool_call>
## Guidelines
1. Think step by step about what needs to be done
2. Use only the tools that are available to you
3. Provide clear reasoning for each tool call
4. If a tool call is rejected, adapt your approach based on the feedback
5. When the task is complete, indicate this clearly:
<task_complete>
Brief summary of what was accomplished
</task_complete>
## Important
- Only propose ONE tool call at a time
- Wait for the result before proposing the next action
- If you encounter an error, analyze it and try an alternative approach
- If you need clarification, ask using <needs_clarification>your question</needs_clarification>`;
}
/**
* Reset the conversation state
*/
public reset(): void {
this.messageHistory = [];
}
}

View File

@@ -1,352 +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[] = [];
constructor(options: interfaces.IDualAgentOptions) {
this.options = {
maxIterations: 20,
maxConsecutiveRejections: 3,
defaultProvider: 'openai',
...options,
};
// Create SmartAi instance
this.smartai = new plugins.smartai.SmartAi(options);
// Get providers
this.driverProvider = this.getProviderByName(this.options.defaultProvider!);
this.guardianProvider = this.options.guardianProvider
? this.getProviderByName(this.options.guardianProvider)
: this.driverProvider;
// Create agents
this.driver = new DriverAgent(this.driverProvider, options.driverSystemMessage);
this.guardian = new GuardianAgent(this.guardianProvider, options.guardianPolicyPrompt);
}
/**
* 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;
}
}
/**
* Register a custom tool
*/
public registerTool(tool: BaseToolWrapper): void {
this.tools.set(tool.name, tool);
this.driver.registerTool(tool);
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);
}
}
/**
* Initialize all tools (eager loading)
*/
public async start(): Promise<void> {
// Start smartai
await this.smartai.start();
// 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);
await this.smartai.stop();
this.isRunning = false;
this.driver.reset();
}
/**
* Run a task through the dual-agent system
*/
public async run(task: string): Promise<interfaces.IDualAgentRunResult> {
if (!this.isRunning) {
throw new Error('Orchestrator not started. Call start() first.');
}
this.conversationHistory = [];
let iterations = 0;
let consecutiveRejections = 0;
let completed = false;
let finalResult: string | null = null;
// Add initial task to history
this.conversationHistory.push({
role: 'user',
content: task,
});
// Start the driver with the task
let driverResponse = await this.driver.startTask(task);
this.conversationHistory.push(driverResponse);
while (
iterations < this.options.maxIterations! &&
consecutiveRejections < this.options.maxConsecutiveRejections! &&
!completed
) {
iterations++;
// Check if task is complete
if (this.driver.isTaskComplete(driverResponse.content)) {
completed = true;
finalResult = this.driver.extractTaskResult(driverResponse.content) || driverResponse.content;
break;
}
// Check if driver needs clarification
if (this.driver.needsClarification(driverResponse.content)) {
// Return with clarification needed status
return {
success: false,
completed: false,
result: driverResponse.content,
iterations,
history: this.conversationHistory,
status: 'clarification_needed',
};
}
// Parse tool call proposals
const proposals = this.driver.parseToolCallProposals(driverResponse.content);
if (proposals.length === 0) {
// No tool calls, continue the conversation
driverResponse = await this.driver.continueWithMessage(
'Please either use a tool to make progress on the task, or indicate that the task is complete with <task_complete>summary</task_complete>.'
);
this.conversationHistory.push(driverResponse);
continue;
}
// Process the first proposal (one at a time)
const proposal = proposals[0];
// Quick validation first
const quickDecision = this.guardian.quickValidate(proposal);
let decision: interfaces.IGuardianDecision;
if (quickDecision) {
decision = quickDecision;
} else {
// Full AI evaluation
decision = await this.guardian.evaluate(proposal, task);
}
if (decision.decision === 'approve') {
consecutiveRejections = 0;
// 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 {
const result = await tool.execute(proposal.action, proposal.params);
// Send result to driver
const resultMessage = result.success
? `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${JSON.stringify(result.result, null, 2)}`
: `TOOL ERROR (${proposal.toolName}.${proposal.action}):\n${result.error}`;
this.conversationHistory.push({
role: 'system',
content: resultMessage,
toolCall: proposal,
toolResult: result,
});
driverResponse = await this.driver.continueWithMessage(resultMessage);
this.conversationHistory.push(driverResponse);
} catch (error) {
const errorMessage = `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`;
driverResponse = await this.driver.continueWithMessage(
`TOOL ERROR: ${errorMessage}\n\nPlease try a different approach.`
);
this.conversationHistory.push(driverResponse);
}
} else {
// Rejected
consecutiveRejections++;
// 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,
});
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';
} else if (consecutiveRejections >= this.options.maxConsecutiveRejections!) {
status = 'max_rejections_reached';
}
}
return {
success: completed,
completed,
result: finalResult || driverResponse.content,
iterations,
history: this.conversationHistory,
status,
};
}
/**
* Continue an existing task with user input
*/
public async continueTask(userInput: string): Promise<interfaces.IDualAgentRunResult> {
if (!this.isRunning) {
throw new Error('Orchestrator not started. Call start() first.');
}
this.conversationHistory.push({
role: 'user',
content: userInput,
});
const driverResponse = await this.driver.continueWithMessage(userInput);
this.conversationHistory.push(driverResponse);
// Continue the run loop
// For simplicity, we return the current state - full continuation would need refactoring
return {
success: false,
completed: false,
result: driverResponse.content,
iterations: 1,
history: this.conversationHistory,
status: 'in_progress',
};
}
/**
* Get the conversation history
*/
public getHistory(): interfaces.IAgentMessage[] {
return [...this.conversationHistory];
}
/**
* Update the guardian policy
*/
public setGuardianPolicy(policyPrompt: string): void {
this.guardian.setPolicy(policyPrompt);
}
/**
* Check if orchestrator is running
*/
public isActive(): boolean {
return this.isRunning;
}
/**
* Get registered tool names
*/
public getToolNames(): string[] {
return Array.from(this.tools.keys());
}
}

View File

@@ -1,241 +0,0 @@
import * as plugins from './plugins.js';
import * as interfaces from './smartagent.interfaces.js';
import type { BaseToolWrapper } from './smartagent.tools.base.js';
/**
* GuardianAgent - Evaluates tool call proposals against a policy
* Uses AI reasoning to approve or reject tool calls
*/
export class GuardianAgent {
private provider: plugins.smartai.MultiModalModel;
private policyPrompt: string;
private tools: Map<string, BaseToolWrapper> = new Map();
constructor(
provider: plugins.smartai.MultiModalModel,
policyPrompt: string
) {
this.provider = provider;
this.policyPrompt = policyPrompt;
}
/**
* Register a tool for reference during evaluation
*/
public registerTool(tool: BaseToolWrapper): void {
this.tools.set(tool.name, tool);
}
/**
* Evaluate a tool call proposal against the policy
*/
public async evaluate(
proposal: interfaces.IToolCallProposal,
taskContext: string
): Promise<interfaces.IGuardianDecision> {
// Get the tool to generate a human-readable summary
const tool = this.tools.get(proposal.toolName);
let callSummary = `${proposal.toolName}.${proposal.action}(${JSON.stringify(proposal.params)})`;
if (tool) {
try {
callSummary = tool.getCallSummary(proposal.action, proposal.params);
} catch {
// Fallback to basic summary
}
}
// Build the evaluation prompt
const evaluationPrompt = this.buildEvaluationPrompt(
proposal,
callSummary,
taskContext
);
// Get response from provider
const response = await this.provider.chat({
systemMessage: this.buildGuardianSystemMessage(),
userMessage: evaluationPrompt,
messageHistory: [],
});
// Parse the decision from the response
return this.parseDecision(response.message, proposal);
}
/**
* Build the system message for the Guardian
*/
private buildGuardianSystemMessage(): string {
return `You are a Guardian AI responsible for evaluating tool call proposals.
## Your Role
You evaluate whether proposed tool calls are safe and aligned with the policy.
## Policy to Enforce
${this.policyPrompt}
## Response Format
For EVERY evaluation, respond with a decision in this exact format:
<guardian_decision>
<decision>approve OR reject</decision>
<reason>Your detailed explanation</reason>
<concerns>List any concerns, even if approving</concerns>
<suggestions>Alternative approaches if rejecting</suggestions>
</guardian_decision>
## Guidelines
1. Carefully analyze what the tool call will do
2. Consider security implications
3. Check against the policy requirements
4. If uncertain, err on the side of caution (reject)
5. Provide actionable feedback when rejecting`;
}
/**
* Build the evaluation prompt for a specific proposal
*/
private buildEvaluationPrompt(
proposal: interfaces.IToolCallProposal,
callSummary: string,
taskContext: string
): string {
const toolInfo = this.tools.get(proposal.toolName);
const toolDescription = toolInfo ? toolInfo.getFullDescription() : 'Unknown tool';
return `## Task Context
${taskContext}
## Tool Being Used
${toolDescription}
## Proposed Tool Call
- **Tool**: ${proposal.toolName}
- **Action**: ${proposal.action}
- **Parameters**: ${JSON.stringify(proposal.params, null, 2)}
## Human-Readable Summary
${callSummary}
## Driver's Reasoning
${proposal.reasoning || 'No reasoning provided'}
---
Evaluate this tool call against the policy. Should it be approved or rejected?`;
}
/**
* Parse the guardian decision from the response
*/
private parseDecision(
response: string,
proposal: interfaces.IToolCallProposal
): interfaces.IGuardianDecision {
// Try to extract from XML tags
const decisionMatch = response.match(/<decision>(.*?)<\/decision>/s);
const reasonMatch = response.match(/<reason>([\s\S]*?)<\/reason>/);
const concernsMatch = response.match(/<concerns>([\s\S]*?)<\/concerns>/);
const suggestionsMatch = response.match(/<suggestions>([\s\S]*?)<\/suggestions>/);
// Determine decision
let decision: 'approve' | 'reject' = 'reject';
if (decisionMatch) {
const decisionText = decisionMatch[1].trim().toLowerCase();
decision = decisionText.includes('approve') ? 'approve' : 'reject';
} else {
// Fallback: look for approval keywords in the response
const lowerResponse = response.toLowerCase();
if (
lowerResponse.includes('approved') ||
lowerResponse.includes('i approve') ||
lowerResponse.includes('looks safe')
) {
decision = 'approve';
}
}
// Extract reason
let reason = reasonMatch ? reasonMatch[1].trim() : '';
if (!reason) {
// Use the full response as reason if no tag found
reason = response.substring(0, 500);
}
// Extract concerns
const concerns: string[] = [];
if (concernsMatch) {
const concernsText = concernsMatch[1].trim();
if (concernsText && concernsText.toLowerCase() !== 'none') {
// Split by newlines or bullet points
const concernLines = concernsText.split(/[\n\r]+/).map(l => l.trim()).filter(l => l);
concerns.push(...concernLines);
}
}
// Extract suggestions
const suggestions = suggestionsMatch ? suggestionsMatch[1].trim() : undefined;
return {
decision,
reason,
concerns: concerns.length > 0 ? concerns : undefined,
suggestions: suggestions && suggestions.toLowerCase() !== 'none' ? suggestions : undefined,
};
}
/**
* Quick validation without AI (for obviously safe/unsafe operations)
* Returns null if AI evaluation is needed
*/
public quickValidate(proposal: interfaces.IToolCallProposal): interfaces.IGuardianDecision | null {
// Check if tool exists
if (!this.tools.has(proposal.toolName)) {
return {
decision: 'reject',
reason: `Unknown tool: ${proposal.toolName}`,
};
}
// Check if action exists
const tool = this.tools.get(proposal.toolName)!;
const validAction = tool.actions.find(a => a.name === proposal.action);
if (!validAction) {
return {
decision: 'reject',
reason: `Unknown action "${proposal.action}" for tool "${proposal.toolName}". Available actions: ${tool.actions.map(a => a.name).join(', ')}`,
};
}
// Check required parameters
const schema = validAction.parameters;
if (schema && schema.required && Array.isArray(schema.required)) {
for (const requiredParam of schema.required as string[]) {
if (!(requiredParam in proposal.params)) {
return {
decision: 'reject',
reason: `Missing required parameter: ${requiredParam}`,
};
}
}
}
// Needs full AI evaluation
return null;
}
/**
* Update the policy prompt
*/
public setPolicy(policyPrompt: string): void {
this.policyPrompt = policyPrompt;
}
/**
* Get current policy
*/
public getPolicy(): string {
return this.policyPrompt;
}
}

View File

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

View File

@@ -1,210 +1,54 @@
import * as plugins from './plugins.js';
import type { ToolSet, ModelMessage, LanguageModelV3 } from './plugins.js';
// ================================
// Agent Configuration Interfaces
// ================================
/**
* Configuration options for the DualAgentOrchestrator
*/
export interface IDualAgentOptions extends plugins.smartai.ISmartAiOptions {
/** 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;
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;
/**
* Maximum number of LLM↔tool round trips.
* Each step may execute multiple tools in parallel.
* Default: 20
*/
maxSteps?: number;
/** Prior conversation messages to include */
messages?: ModelMessage[];
/** Called for each streamed text delta */
onToken?: (delta: string) => void;
/** Called when a tool call starts */
onToolCall?: (toolName: string, input: unknown) => void;
/** Called when a tool call completes */
onToolResult?: (toolName: string, result: unknown) => void;
/**
* Called when total token usage approaches the model's context limit.
* Receives the full message history and must return a compacted replacement.
* If not provided, runAgent throws a ContextOverflowError instead.
*/
onContextOverflow?: (messages: ModelMessage[]) => Promise<ModelMessage[]>;
/** AbortSignal to cancel the run mid-flight */
abort?: AbortSignal;
}
// ================================
// Message Interfaces
// ================================
/**
* Represents a message in the agent's conversation history
*/
export interface IAgentMessage {
role: 'system' | 'user' | 'assistant' | 'tool' | 'guardian';
content: string;
toolName?: string;
toolResult?: unknown;
toolCall?: IToolCallProposal;
guardianDecision?: IGuardianDecision;
timestamp?: Date;
export interface IAgentRunResult {
/** Final text output from the model */
text: string;
/** All messages in the completed conversation */
messages: ModelMessage[];
/** Total steps taken */
steps: number;
/** Finish reason from the final step */
finishReason: string;
/** Accumulated token usage across all steps */
usage: { inputTokens: number; outputTokens: number; totalTokens: number };
}
// ================================
// Tool Interfaces
// ================================
/**
* Represents an action that a tool can perform
*/
export interface IToolAction {
/** Action name (e.g., 'read', 'write', 'delete') */
name: string;
/** Description of what this action does */
description: string;
/** JSON schema for action parameters */
parameters: Record<string, unknown>;
}
/**
* 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;
}
/**
* 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;
}
// ================================
// Utility Types
// ================================
/**
* Available tool names
*/
export type TToolName = 'filesystem' | 'http' | 'browser' | 'shell';
/**
* Generate a unique proposal ID
*/
export function generateProposalId(): string {
return `proposal_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
export class ContextOverflowError extends Error {
constructor(message = 'Agent context limit reached and no onContextOverflow handler provided') {
super(message);
this.name = 'ContextOverflowError';
}
}

View File

@@ -1,80 +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;
/**
* 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
*/
public getFullDescription(): string {
const actionDescriptions = this.actions
.map((a) => ` - ${a.name}: ${a.description}`)
.join('\n');
return `${this.name}: ${this.description}\nActions:\n${actionDescriptions}`;
}
/**
* Get the JSON schema for a specific action
*/
public getActionSchema(action: string): Record<string, unknown> | undefined {
const actionDef = this.actions.find((a) => a.name === action);
return actionDef?.parameters;
}
}

View File

@@ -1,200 +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 getCallSummary(action: string, params: Record<string, unknown>): string {
switch (action) {
case 'screenshot':
return `Take screenshot of "${params.url}"`;
case 'pdf':
return `Generate PDF from "${params.url}"`;
case 'evaluate': {
const script = params.script as string;
const preview = script.length > 100 ? script.substring(0, 100) + '...' : script;
return `Execute JavaScript on "${params.url}": "${preview}"`;
}
case 'getPageContent':
return `Get text content and title from "${params.url}"`;
default:
return `Unknown action: ${action}`;
}
}
}

View File

@@ -1,191 +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 getCallSummary(action: string, params: Record<string, unknown>): string {
const code = params.code as string;
const permissions = (params.permissions as string[]) || [];
// Create a preview of the code (first 100 chars)
const codePreview = code.length > 100 ? code.substring(0, 100) + '...' : code;
// Escape newlines for single-line display
const cleanPreview = codePreview.replace(/\n/g, '\\n');
const permissionInfo = permissions.length > 0
? ` [permissions: ${permissions.join(', ')}]`
: ' [sandboxed - no permissions]';
switch (action) {
case 'execute':
return `Execute Deno code${permissionInfo}: "${cleanPreview}"`;
case 'executeWithResult':
return `Execute Deno code and parse JSON result${permissionInfo}: "${cleanPreview}"`;
default:
return `Unknown action: ${action}`;
}
}
}

View File

@@ -1,379 +0,0 @@
import * as plugins from './plugins.js';
import * as interfaces from './smartagent.interfaces.js';
import { BaseToolWrapper } from './smartagent.tools.base.js';
/**
* 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';
public actions: interfaces.IToolAction[] = [
{
name: 'read',
description: 'Read the contents of a file',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Absolute path to the file' },
encoding: {
type: 'string',
enum: ['utf8', 'binary', 'base64'],
default: 'utf8',
description: 'File encoding',
},
},
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'],
},
},
];
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 encoding = (params.encoding as string) || 'utf8';
const content = await this.smartfs
.file(params.path as string)
.encoding(encoding as 'utf8' | 'binary' | 'base64')
.read();
return {
success: true,
result: {
path: params.path,
content: content.toString(),
encoding,
},
};
}
case 'write': {
const encoding = (params.encoding as string) || 'utf8';
await this.smartfs
.file(params.path as string)
.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': {
await this.smartfs.file(params.path as string).append(params.content as string);
return {
success: true,
result: {
path: params.path,
appended: true,
},
};
}
case 'list': {
let dir = this.smartfs.directory(params.path as string);
if (params.recursive) {
dir = dir.recursive();
}
if (params.filter) {
dir = dir.filter(params.filter as string);
}
const entries = await dir.list();
return {
success: true,
result: {
path: params.path,
entries,
count: entries.length,
},
};
}
case 'delete': {
const path = params.path as string;
// Check if it's a directory or file
const exists = await this.smartfs.file(path).exists();
if (exists) {
// Try to get stats to check if it's a directory
try {
const stats = await this.smartfs.file(path).stat();
if (stats.isDirectory && params.recursive) {
await this.smartfs.directory(path).recursive().delete();
} else {
await this.smartfs.file(path).delete();
}
} catch {
await this.smartfs.file(path).delete();
}
}
return {
success: true,
result: {
path,
deleted: true,
},
};
}
case 'exists': {
const exists = await this.smartfs.file(params.path as string).exists();
return {
success: true,
result: {
path: params.path,
exists,
},
};
}
case 'stat': {
const stats = await this.smartfs.file(params.path as string).stat();
return {
success: true,
result: {
path: params.path,
stats,
},
};
}
case 'copy': {
await this.smartfs.file(params.source as string).copy(params.destination as string);
return {
success: true,
result: {
source: params.source,
destination: params.destination,
copied: true,
},
};
}
case 'move': {
await this.smartfs.file(params.source as string).move(params.destination as string);
return {
success: true,
result: {
source: params.source,
destination: params.destination,
moved: true,
},
};
}
case 'mkdir': {
let dir = this.smartfs.directory(params.path as string);
if (params.recursive !== false) {
dir = dir.recursive();
}
await dir.create();
return {
success: true,
result: {
path: params.path,
created: true,
},
};
}
default:
return {
success: false,
error: `Unknown action: ${action}`,
};
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
public getCallSummary(action: string, params: Record<string, unknown>): string {
switch (action) {
case 'read':
return `Read file "${params.path}" with encoding ${params.encoding || 'utf8'}`;
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)' : ''}`;
default:
return `Unknown action: ${action}`;
}
}
}

View File

@@ -1,205 +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 getCallSummary(action: string, params: Record<string, unknown>): string {
const method = action.toUpperCase();
let summary = `${method} request to "${params.url}"`;
if (params.query && Object.keys(params.query as object).length > 0) {
const queryStr = JSON.stringify(params.query);
summary += ` with query: ${queryStr.length > 50 ? queryStr.substring(0, 50) + '...' : queryStr}`;
}
if (params.body) {
const bodyStr = JSON.stringify(params.body);
const preview = bodyStr.length > 100 ? bodyStr.substring(0, 100) + '...' : bodyStr;
summary += ` with body: ${preview}`;
}
if (params.headers && Object.keys(params.headers as object).length > 0) {
const headerKeys = Object.keys(params.headers as object).join(', ');
summary += ` with headers: [${headerKeys}]`;
}
return summary;
}
}

View File

@@ -1,182 +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 getCallSummary(action: string, params: Record<string, unknown>): string {
switch (action) {
case 'execute': {
const command = params.command as string;
const args = (params.args as string[]) || [];
const fullCommand = [command, ...args].join(' ');
let summary = `Execute: ${fullCommand}`;
if (params.cwd) {
summary += ` (in ${params.cwd})`;
}
if (params.timeout) {
summary += ` [timeout: ${params.timeout}ms]`;
}
if (params.env && Object.keys(params.env as object).length > 0) {
const envKeys = Object.keys(params.env as object).join(', ');
summary += ` [env: ${envKeys}]`;
}
return summary;
}
case 'which':
return `Check if command "${params.command}" exists and get its path`;
default:
return `Unknown action: ${action}`;
}
}
}

View File

@@ -0,0 +1,39 @@
// Truncation logic derived from opencode (MIT) — https://github.com/sst/opencode
const MAX_LINES = 2000;
const MAX_BYTES = 50 * 1024; // 50 KB
export interface ITruncateResult {
content: string;
truncated: boolean;
/** Set when truncated: describes what was dropped */
notice?: string;
}
export function truncateOutput(
text: string,
options?: { maxLines?: number; maxBytes?: number },
): ITruncateResult {
const maxLines = options?.maxLines ?? MAX_LINES;
const maxBytes = options?.maxBytes ?? MAX_BYTES;
const lines = text.split('\n');
const totalBytes = Buffer.byteLength(text, 'utf-8');
if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false };
}
const out: string[] = [];
let bytes = 0;
for (let i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], 'utf-8') + (i > 0 ? 1 : 0);
if (bytes + size > maxBytes) break;
out.push(lines[i]);
bytes += size;
}
const kept = out.length;
const dropped = lines.length - kept;
const notice = `\n[Output truncated: showing ${kept}/${lines.length} lines. ${dropped} lines omitted.]`;
return { content: out.join('\n') + notice, truncated: true, notice };
}

1
ts_compaction/index.ts Normal file
View File

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

6
ts_compaction/plugins.ts Normal file
View File

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

View File

@@ -0,0 +1,51 @@
import * as plugins from './plugins.js';
const COMPACTION_PROMPT = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work.
When constructing the summary, try to stick to this template:
---
## Goal
[What goal(s) is the user trying to accomplish?]
## Instructions
- [What important instructions did the user give you that are relevant]
## Discoveries
[What notable things were learned during this conversation that would be useful for the next agent to know]
## Accomplished
[What work has been completed, what work is still in progress, and what work is left?]
## Relevant files / directories
[A structured list of relevant files that have been read, edited, or created]
---`;
/**
* Compacts a message history into a summary.
* Pass this as the onContextOverflow handler in IAgentRunOptions.
*
* @param model The same model used by runAgent, or a cheaper small model
* @param messages The full message history that overflowed
* @returns A minimal ModelMessage[] containing the summary as context
*/
export async function compactMessages(
model: plugins.LanguageModelV3,
messages: plugins.ModelMessage[],
): Promise<plugins.ModelMessage[]> {
const result = await plugins.generateText({
model,
messages: [
...messages,
{ role: 'user', content: COMPACTION_PROMPT },
],
});
return [
{
role: 'user',
content: `[Previous conversation summary]\n\n${result.text}\n\n[End of summary. Continue from here.]`,
},
];
}

8
ts_tools/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { filesystemTool } from './tool.filesystem.js';
export type { IFilesystemToolOptions } from './tool.filesystem.js';
export { shellTool } from './tool.shell.js';
export type { IShellToolOptions } from './tool.shell.js';
export { httpTool } from './tool.http.js';
export { jsonTool } from './tool.json.js';
export { truncateOutput } from './plugins.js';
export type { ITruncateResult } from './plugins.js';

30
ts_tools/plugins.ts Normal file
View File

@@ -0,0 +1,30 @@
// node native
import * as path from 'path';
import * as fs from 'fs';
export { path, fs };
// zod
import { z } from 'zod';
export { z };
// ai-sdk
import { tool } from '@push.rocks/smartai';
export { tool };
export type { ToolSet } from 'ai';
// @push.rocks scope
import * as smartfs from '@push.rocks/smartfs';
import * as smartshell from '@push.rocks/smartshell';
import * as smartrequest from '@push.rocks/smartrequest';
export { smartfs, smartshell, smartrequest };
// cross-folder import
import { truncateOutput } from '../ts/smartagent.utils.truncation.js';
export { truncateOutput };
export type { ITruncateResult } from '../ts/smartagent.utils.truncation.js';

131
ts_tools/tool.filesystem.ts Normal file
View File

@@ -0,0 +1,131 @@
import * as plugins from './plugins.js';
export interface IFilesystemToolOptions {
/** Restrict file access to this directory. Default: process.cwd() */
rootDir?: string;
}
function validatePath(filePath: string, rootDir?: string): string {
const resolved = plugins.path.resolve(filePath);
if (rootDir) {
const resolvedRoot = plugins.path.resolve(rootDir);
if (!resolved.startsWith(resolvedRoot + plugins.path.sep) && resolved !== resolvedRoot) {
throw new Error(`Access denied: "${filePath}" is outside allowed root "${rootDir}"`);
}
}
return resolved;
}
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
const rootDir = options?.rootDir;
return {
read_file: plugins.tool({
description:
'Read file contents. Returns the full text or a specified line range.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Absolute path to the file'),
startLine: plugins.z
.number()
.optional()
.describe('First line (1-indexed, inclusive)'),
endLine: plugins.z
.number()
.optional()
.describe('Last line (1-indexed, inclusive)'),
}),
execute: async ({
path: filePath,
startLine,
endLine,
}: {
path: string;
startLine?: number;
endLine?: number;
}) => {
const resolved = validatePath(filePath, rootDir);
const content = plugins.fs.readFileSync(resolved, 'utf-8');
if (startLine !== undefined || endLine !== undefined) {
const lines = content.split('\n');
const start = (startLine ?? 1) - 1;
const end = endLine ?? lines.length;
const sliced = lines.slice(start, end).join('\n');
return plugins.truncateOutput(sliced).content;
}
return plugins.truncateOutput(content).content;
},
}),
write_file: plugins.tool({
description:
'Write content to a file (creates parent dirs if needed, overwrites existing).',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Absolute path to the file'),
content: plugins.z.string().describe('Content to write'),
}),
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
const resolved = validatePath(filePath, rootDir);
const dir = plugins.path.dirname(resolved);
plugins.fs.mkdirSync(dir, { recursive: true });
plugins.fs.writeFileSync(resolved, content, 'utf-8');
return `Written ${content.length} characters to ${filePath}`;
},
}),
list_directory: plugins.tool({
description: 'List files and directories at the given path.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Directory path to list'),
recursive: plugins.z
.boolean()
.optional()
.describe('List recursively (default: false)'),
}),
execute: async ({
path: dirPath,
recursive,
}: {
path: string;
recursive?: boolean;
}) => {
const resolved = validatePath(dirPath, rootDir);
function listDir(dir: string, prefix: string = ''): string[] {
const entries = plugins.fs.readdirSync(dir, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const indicator = entry.isDirectory() ? '/' : '';
result.push(`${rel}${indicator}`);
if (recursive && entry.isDirectory()) {
result.push(...listDir(plugins.path.join(dir, entry.name), rel));
}
}
return result;
}
const entries = listDir(resolved);
return plugins.truncateOutput(entries.join('\n')).content;
},
}),
delete_file: plugins.tool({
description: 'Delete a file or empty directory.',
inputSchema: plugins.z.object({
path: plugins.z.string().describe('Path to delete'),
}),
execute: async ({ path: filePath }: { path: string }) => {
const resolved = validatePath(filePath, rootDir);
const stat = plugins.fs.statSync(resolved);
if (stat.isDirectory()) {
plugins.fs.rmdirSync(resolved);
} else {
plugins.fs.unlinkSync(resolved);
}
return `Deleted ${filePath}`;
},
}),
};
}

78
ts_tools/tool.http.ts Normal file
View File

@@ -0,0 +1,78 @@
import * as plugins from './plugins.js';
export function httpTool(): plugins.ToolSet {
return {
http_get: plugins.tool({
description: 'Make an HTTP GET request and return the response.',
inputSchema: plugins.z.object({
url: plugins.z.string().describe('URL to request'),
headers: plugins.z
.record(plugins.z.string())
.optional()
.describe('Request headers'),
}),
execute: async ({
url,
headers,
}: {
url: string;
headers?: Record<string, string>;
}) => {
let req = plugins.smartrequest.default.create().url(url);
if (headers) {
req = req.headers(headers);
}
const response = await req.get();
let body: string;
try {
const json = await response.json();
body = JSON.stringify(json, null, 2);
} catch {
body = await response.text();
}
return plugins.truncateOutput(`HTTP ${response.status}\n${body}`).content;
},
}),
http_post: plugins.tool({
description: 'Make an HTTP POST request with a JSON body.',
inputSchema: plugins.z.object({
url: plugins.z.string().describe('URL to request'),
body: plugins.z
.record(plugins.z.unknown())
.optional()
.describe('JSON body to send'),
headers: plugins.z
.record(plugins.z.string())
.optional()
.describe('Request headers'),
}),
execute: async ({
url,
body,
headers,
}: {
url: string;
body?: Record<string, unknown>;
headers?: Record<string, string>;
}) => {
let req = plugins.smartrequest.default.create().url(url);
if (headers) {
req = req.headers(headers);
}
if (body) {
req = req.json(body);
}
const response = await req.post();
let responseBody: string;
try {
const json = await response.json();
responseBody = JSON.stringify(json, null, 2);
} catch {
responseBody = await response.text();
}
return plugins.truncateOutput(`HTTP ${response.status}\n${responseBody}`).content;
},
}),
};
}

53
ts_tools/tool.json.ts Normal file
View File

@@ -0,0 +1,53 @@
import * as plugins from './plugins.js';
export function jsonTool(): plugins.ToolSet {
return {
json_validate: plugins.tool({
description:
'Validate a JSON string and optionally check for required fields.',
inputSchema: plugins.z.object({
jsonString: plugins.z.string().describe('JSON string to validate'),
requiredFields: plugins.z
.array(plugins.z.string())
.optional()
.describe('Fields that must exist at the top level'),
}),
execute: async ({
jsonString,
requiredFields,
}: {
jsonString: string;
requiredFields?: string[];
}) => {
try {
const parsed = JSON.parse(jsonString);
if (requiredFields?.length) {
const missing = requiredFields.filter((f) => !(f in parsed));
if (missing.length) {
return `Invalid: missing required fields: ${missing.join(', ')}`;
}
}
const type = Array.isArray(parsed) ? 'array' : typeof parsed;
return `Valid JSON (${type})`;
} catch (e) {
return `Invalid JSON: ${(e as Error).message}`;
}
},
}),
json_transform: plugins.tool({
description: 'Parse a JSON string and return it pretty-printed.',
inputSchema: plugins.z.object({
jsonString: plugins.z.string().describe('JSON string to format'),
}),
execute: async ({ jsonString }: { jsonString: string }) => {
try {
const parsed = JSON.parse(jsonString);
return JSON.stringify(parsed, null, 2);
} catch (e) {
return `Error parsing JSON: ${(e as Error).message}`;
}
},
}),
};
}

62
ts_tools/tool.shell.ts Normal file
View File

@@ -0,0 +1,62 @@
import * as plugins from './plugins.js';
export interface IShellToolOptions {
/** Allowed commands whitelist. If empty, all commands are allowed. */
allowedCommands?: string[];
/** Working directory for shell execution */
cwd?: string;
}
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
const smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
return {
run_command: plugins.tool({
description:
'Execute a shell command. Provide the full command string. stdout and stderr are returned.',
inputSchema: plugins.z.object({
command: plugins.z.string().describe('The shell command to execute'),
cwd: plugins.z
.string()
.optional()
.describe('Working directory for the command'),
timeout: plugins.z
.number()
.optional()
.describe('Timeout in milliseconds'),
}),
execute: async ({
command,
cwd,
timeout,
}: {
command: string;
cwd?: string;
timeout?: number;
}) => {
// Validate against allowed commands whitelist
if (options?.allowedCommands?.length) {
const baseCommand = command.split(/\s+/)[0];
if (!options.allowedCommands.includes(baseCommand)) {
return `Command "${baseCommand}" is not in the allowed commands list: ${options.allowedCommands.join(', ')}`;
}
}
// Build full command string with cd prefix if cwd specified
const effectiveCwd = cwd ?? options?.cwd;
const fullCommand = effectiveCwd
? `cd ${JSON.stringify(effectiveCwd)} && ${command}`
: command;
const execResult = await smartshell.exec(fullCommand);
const output =
execResult.exitCode === 0
? execResult.stdout
: `Exit code: ${execResult.exitCode}\nstdout:\n${execResult.stdout}\nstderr:\n${execResult.stderr ?? ''}`;
return plugins.truncateOutput(output).content;
},
}),
};
}