Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8fcdd05af | |||
| 91865e9f57 | |||
| 2947842499 | |||
| c503690d52 | |||
| 38556c8b12 | |||
| f9a9c9fb48 | |||
| 903de44644 | |||
| 5aa69cc998 | |||
| 5ca0c80ea9 | |||
| 940bf3d3ef | |||
| c1b269f301 | |||
| 7cb970f9e2 | |||
| 1fbcf8bb8b | |||
| 4a8789019a | |||
| 0da85a5dcd | |||
| 121e216eea | |||
| eb1058bfb5 | |||
| ecdc125a43 | |||
| 73657be550 | |||
| 4e4d3c0e08 | |||
| 79efe8f6b8 | |||
| 8bcf3257e2 | |||
| 6753553394 | |||
| a46dbd0da6 | |||
| 7379daf4c5 | |||
| 83422b4b0e | |||
| 4310c8086b | |||
| 472a8ed7f8 | |||
| 44137a8710 | |||
| c12a6a7be9 | |||
| 49dcc7a1a1 | |||
| e649e9caab | |||
| c39e7e76b8 | |||
| c24a4306d9 | |||
| 9718048dff | |||
| b1deccaa26 | |||
| 52d1d128c7 | |||
| 60f8bbe1b6 | |||
| b6308d2113 | |||
| e7968a31b1 | |||
| 05e4f03061 | |||
| 37d4069806 | |||
| fe0de36b1a | |||
| e49f35e7de | |||
| 4fbffdd97d | |||
| 560838477f | |||
| 7503fccbf2 | |||
| a76bd0d3e4 | |||
| 1556a9d3e9 | |||
| 19ba58ca40 | |||
| 8662b73adb | |||
| 9e848045f7 | |||
| 8827a55768 | |||
| 37b6e98a81 | |||
| 35911c21de | |||
| 7403e769d0 |
144
changelog.md
144
changelog.md
@@ -1,5 +1,149 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-06 - 3.0.2 - fix(agent)
|
||||
use output parameter when invoking onToolResult instead of toolCall.result
|
||||
|
||||
- Replace (toolCall as any).result with the explicit output parameter when calling options.onToolResult.
|
||||
- Prevents undefined/misread results by aligning the callback with the tool runner's output signature.
|
||||
|
||||
## 2026-03-06 - 3.0.1 - fix(readme)
|
||||
adjust ASCII art in README to fix box widths and spacing in agent diagram
|
||||
|
||||
- Updated readme.md diagram: expanded 'Messages' box width and realigned 'streamText' and 'Tools' columns
|
||||
- Documentation-only change; no code or behavior affected
|
||||
|
||||
## 2026-03-06 - 3.0.0 - BREAKING CHANGE(api)
|
||||
Migrate public API to ai-sdk v6 and refactor core agent architecture: replace class-based DualAgent/Driver/Guardian with a single runAgent function; introduce ts_tools factories for tools, a compactMessages compaction subpath, and truncateOutput utility; simplify ToolRegistry to return ToolSet and remove legacy BaseToolWrapper/tool classes; update package exports and dependencies and bump major version.
|
||||
|
||||
- Major API break: DualAgent/Driver/Guardian classes and many legacy tool wrapper classes were removed and replaced by runAgent and functional tool factories.
|
||||
- Tooling refactor: new ts_tools (filesystem, shell, http, json) produce ToolSet-style tools; ToolRegistry now stores ToolSet and exposes getTools().
|
||||
- Context management: added compactMessages in ts_compaction for onContextOverflow handling and truncateOutput util to limit tool outputs.
|
||||
- Package changes: package.json bumped to 2.0.0, added exports map, updated devDependencies and dependencies (@push.rocks/smartai -> ^2.0.0, ai -> ^6.0.0, zod added).
|
||||
- Tests updated/added to reflect new API (unit tests and an end-to-end test added).
|
||||
- Consumers must update imports/usages (e.g. import runAgent, tool, z, stepCountIs and new subpath exports) — this is a breaking change.
|
||||
|
||||
## 2026-01-20 - 1.8.0 - feat(tools)
|
||||
add ToolRegistry, ToolSearchTool and ExpertTool to support on-demand tool visibility, discovery, activation, and expert/subagent tooling; extend DualAgentOrchestrator API and interfaces to manage tool lifecycle
|
||||
|
||||
- Introduce ToolRegistry to manage tool registration, visibility (initial vs on-demand), activation, initialization, cleanup, and search
|
||||
- Add ToolSearchTool providing search/list/activate/details actions for discovering and enabling on-demand tools
|
||||
- Add ExpertTool to wrap a DualAgentOrchestrator as a sub-agent (expert) tool and the IExpertConfig interface
|
||||
- Extend DualAgentOrchestrator API: registerTool(tool, options?), registerExpert(config), enableToolSearch(), getRegistry(); registerStandardTools/start now initialize visible tools via registry
|
||||
- Add IToolRegistrationOptions and IToolMetadata/IExpertConfig types to smartagent.interfaces and export ToolRegistry/ToolSearchTool/ExpertTool in public entry points
|
||||
- Documentation updates (readme) describing tool visibility, tool search, and expert/subagent system
|
||||
|
||||
## 2026-01-20 - 1.7.0 - feat(docs)
|
||||
document native tool calling support and update README to clarify standard and additional tools
|
||||
|
||||
- Add 'Native Tool Calling' section documenting useNativeToolCalling option and behavior for providers (e.g., Ollama).
|
||||
- Explain tool name mapping when native tool calling is enabled (toolName_actionName) and streaming markers ([THINKING], [OUTPUT]).
|
||||
- Add example showing enabling useNativeToolCalling and note ollamaToken config option (Ollama endpoint).
|
||||
- Clarify that registerStandardTools() registers five tools (Filesystem, HTTP, Shell, Browser, Deno) and that JsonValidatorTool must be registered manually as an additional tool.
|
||||
- Documentation-only changes (README updates) — no code functionality changed in this diff.
|
||||
|
||||
## 2026-01-20 - 1.6.2 - fix(release)
|
||||
bump version to 1.6.2
|
||||
|
||||
- No source changes detected in the diff
|
||||
- Current package.json version is 1.6.1
|
||||
- Recommend a patch bump to 1.6.2 for a release
|
||||
|
||||
## 2026-01-20 - 1.6.1 - fix(driveragent)
|
||||
include full message history for tool results and use a continuation prompt when invoking provider.collectStreamResponse
|
||||
|
||||
- When toolName is provided, include the full messageHistory (do not slice off the last message) so tool result messages are preserved.
|
||||
- Set userMessage to a continuation prompt ('Continue with the task. The tool result has been provided above.') when handling tool results to avoid repeating the tool output.
|
||||
- Keeps existing maxHistoryMessages trimming and validates provider.collectStreamResponse is available before streaming.
|
||||
|
||||
## 2026-01-20 - 1.6.0 - feat(smartagent)
|
||||
record native tool results in message history by adding optional toolName to continueWithNativeTools and passing tool identifier from DualAgent
|
||||
|
||||
- continueWithNativeTools(message, toolName?) now accepts an optional toolName; when provided the message is stored with role 'tool' and includes a toolName property (cast to ChatMessage)
|
||||
- DualAgent constructs a toolNameForHistory as `${proposal.toolName}_${proposal.action}` and forwards it to continueWithNativeTools in both normal and error flows
|
||||
- Preserves tool-origin information in the conversation history to support native tool calling and tracking
|
||||
|
||||
## 2026-01-20 - 1.5.4 - fix(driveragent)
|
||||
prevent duplicate thinking/output markers during token streaming and mark transitions
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
47
package.json
47
package.json
@@ -1,32 +1,47 @@
|
||||
{
|
||||
"name": "@push.rocks/smartagent",
|
||||
"version": "1.1.1",
|
||||
"version": "3.0.2",
|
||||
"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
3158
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
664
readme.md
@@ -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
239
test/test.agent-e2e.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { runAgent, tool, z, ToolRegistry } from '../ts/index.js';
|
||||
import { filesystemTool, shellTool } from '../ts_tools/index.js';
|
||||
|
||||
const testQenv = new qenv.Qenv('./', './.nogit/');
|
||||
|
||||
let model: any;
|
||||
const workDir = '/tmp/smartagent-e2e-' + Date.now();
|
||||
|
||||
tap.test('setup: create model and workspace', async () => {
|
||||
const apiKey = await testQenv.getEnvVarOnDemand('ANTHROPIC_TOKEN');
|
||||
if (!apiKey) {
|
||||
console.log('ANTHROPIC_TOKEN not set — skipping all E2E tests');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { getModel } = await import('@push.rocks/smartai');
|
||||
model = getModel({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
apiKey,
|
||||
});
|
||||
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
console.log(` Workspace: ${workDir}`);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 1: Simple tool call
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should call a single tool and incorporate the result', async () => {
|
||||
let toolCalled = false;
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'What is the current time? Use the get_time tool.',
|
||||
system: 'You are a helpful assistant. Use tools when asked.',
|
||||
tools: {
|
||||
get_time: tool({
|
||||
description: 'Returns the current ISO timestamp',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
toolCalled = true;
|
||||
return new Date().toISOString();
|
||||
},
|
||||
}),
|
||||
},
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
console.log(` Response: ${result.text.substring(0, 150)}`);
|
||||
console.log(` Steps: ${result.steps}, Tokens: ${result.usage.totalTokens}`);
|
||||
|
||||
expect(toolCalled).toBeTrue();
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.usage.totalTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 2: Multiple tools — agent chooses which to use
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should pick the right tool from multiple options', async () => {
|
||||
const callLog: string[] = [];
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Add 7 and 35 using the calculator tool.',
|
||||
system: 'You are a helpful assistant. Use the appropriate tool to answer.',
|
||||
tools: {
|
||||
calculator: tool({
|
||||
description: 'Perform arithmetic. Supports add, subtract, multiply, divide.',
|
||||
inputSchema: z.object({
|
||||
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
}),
|
||||
execute: async ({ operation, a, b }: { operation: string; a: number; b: number }) => {
|
||||
callLog.push(`calculator:${operation}(${a}, ${b})`);
|
||||
switch (operation) {
|
||||
case 'add': return String(a + b);
|
||||
case 'subtract': return String(a - b);
|
||||
case 'multiply': return String(a * b);
|
||||
case 'divide': return b !== 0 ? String(a / b) : 'Error: division by zero';
|
||||
default: return 'Unknown operation';
|
||||
}
|
||||
},
|
||||
}),
|
||||
get_weather: tool({
|
||||
description: 'Get current weather for a city',
|
||||
inputSchema: z.object({ city: z.string() }),
|
||||
execute: async () => {
|
||||
callLog.push('get_weather');
|
||||
return 'Sunny, 22°C';
|
||||
},
|
||||
}),
|
||||
},
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
console.log(` Tool calls: ${callLog.join(', ')}`);
|
||||
console.log(` Response: ${result.text.substring(0, 150)}`);
|
||||
|
||||
expect(callLog.some((c) => c.startsWith('calculator:add'))).toBeTrue();
|
||||
expect(callLog).not.toContain('get_weather');
|
||||
expect(result.text).toInclude('42');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 3: Multi-step — agent uses filesystem tools
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should use filesystem tools for a multi-step task', async () => {
|
||||
const fsTools = filesystemTool({ rootDir: workDir });
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: `Create a file called "greeting.txt" in ${workDir} with the content "Hello from smartagent!". Then read it back and tell me what it says.`,
|
||||
system: 'You are a helpful assistant that works with files. Use the provided tools.',
|
||||
tools: fsTools,
|
||||
maxSteps: 10,
|
||||
});
|
||||
|
||||
console.log(` Steps: ${result.steps}`);
|
||||
console.log(` Response: ${result.text.substring(0, 200)}`);
|
||||
|
||||
// Verify the file was actually created
|
||||
const filePath = path.join(workDir, 'greeting.txt');
|
||||
expect(fs.existsSync(filePath)).toBeTrue();
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toInclude('Hello from smartagent');
|
||||
expect(result.steps).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 4: ToolRegistry usage
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should work with ToolRegistry', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
|
||||
registry.register('random_number', tool({
|
||||
description: 'Generate a random integer between min and max (inclusive)',
|
||||
inputSchema: z.object({
|
||||
min: z.number().describe('Minimum value'),
|
||||
max: z.number().describe('Maximum value'),
|
||||
}),
|
||||
execute: async ({ min, max }: { min: number; max: number }) => {
|
||||
const value = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
return String(value);
|
||||
},
|
||||
}));
|
||||
|
||||
registry.register('is_even', tool({
|
||||
description: 'Check if a number is even',
|
||||
inputSchema: z.object({ number: z.number() }),
|
||||
execute: async ({ number: n }: { number: number }) => {
|
||||
return n % 2 === 0 ? 'Yes, it is even' : 'No, it is odd';
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Generate a random number between 1 and 100, then check if it is even or odd. Tell me both the number and whether it is even.',
|
||||
system: 'You are a helpful assistant. Use tools step by step.',
|
||||
tools: registry.getTools(),
|
||||
maxSteps: 10,
|
||||
});
|
||||
|
||||
console.log(` Response: ${result.text.substring(0, 200)}`);
|
||||
expect(result.text).toBeTruthy();
|
||||
expect(result.steps).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 5: Streaming callbacks
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should fire onToken and onToolCall callbacks', async () => {
|
||||
const tokens: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Use the echo tool to echo "test123".',
|
||||
system: 'You are a helpful assistant. Use tools when asked.',
|
||||
tools: {
|
||||
echo: tool({
|
||||
description: 'Echo back the provided text',
|
||||
inputSchema: z.object({ text: z.string() }),
|
||||
execute: async ({ text }: { text: string }) => text,
|
||||
}),
|
||||
},
|
||||
maxSteps: 5,
|
||||
onToken: (delta) => tokens.push(delta),
|
||||
onToolCall: (name) => toolCalls.push(name),
|
||||
});
|
||||
|
||||
console.log(` Streamed ${tokens.length} token chunks`);
|
||||
console.log(` Tool calls observed: ${toolCalls.join(', ')}`);
|
||||
|
||||
expect(tokens.length).toBeGreaterThan(0);
|
||||
expect(toolCalls).toContain('echo');
|
||||
expect(result.text).toInclude('test123');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Test 6: Shell tool integration
|
||||
// ============================================================
|
||||
|
||||
tap.test('agent should use shell tool to run a command', async () => {
|
||||
const tools = shellTool();
|
||||
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: `Run the command "echo hello_smartagent" and tell me what it outputs.`,
|
||||
system: 'You are a helpful assistant that can run shell commands.',
|
||||
tools,
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
console.log(` Response: ${result.text.substring(0, 200)}`);
|
||||
expect(result.text).toInclude('hello_smartagent');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Cleanup
|
||||
// ============================================================
|
||||
|
||||
tap.test('cleanup: remove workspace', async () => {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
console.log(` Cleaned up ${workDir}`);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
294
test/test.ts
294
test/test.ts
@@ -1,150 +1,188 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartagent from '../ts/index.js';
|
||||
import { filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
|
||||
import { compactMessages } from '../ts_compaction/index.js';
|
||||
|
||||
// Test exports
|
||||
tap.test('should export DualAgentOrchestrator class', async () => {
|
||||
expect(smartagent.DualAgentOrchestrator).toBeTypeOf('function');
|
||||
// ============================================================
|
||||
// Core exports
|
||||
// ============================================================
|
||||
|
||||
tap.test('should export runAgent function', async () => {
|
||||
expect(smartagent.runAgent).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export DriverAgent class', async () => {
|
||||
expect(smartagent.DriverAgent).toBeTypeOf('function');
|
||||
tap.test('should export ToolRegistry class', async () => {
|
||||
expect(smartagent.ToolRegistry).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export GuardianAgent class', async () => {
|
||||
expect(smartagent.GuardianAgent).toBeTypeOf('function');
|
||||
tap.test('should export ContextOverflowError class', async () => {
|
||||
expect(smartagent.ContextOverflowError).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export BaseToolWrapper class', async () => {
|
||||
expect(smartagent.BaseToolWrapper).toBeTypeOf('function');
|
||||
tap.test('should export truncateOutput function', async () => {
|
||||
expect(smartagent.truncateOutput).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// Test standard tools exports
|
||||
tap.test('should export FilesystemTool class', async () => {
|
||||
expect(smartagent.FilesystemTool).toBeTypeOf('function');
|
||||
tap.test('should re-export tool helper', async () => {
|
||||
expect(smartagent.tool).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export HttpTool class', async () => {
|
||||
expect(smartagent.HttpTool).toBeTypeOf('function');
|
||||
tap.test('should re-export z (zod)', async () => {
|
||||
expect(smartagent.z).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should export ShellTool class', async () => {
|
||||
expect(smartagent.ShellTool).toBeTypeOf('function');
|
||||
tap.test('should re-export stepCountIs', async () => {
|
||||
expect(smartagent.stepCountIs).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
tap.test('should export BrowserTool class', async () => {
|
||||
expect(smartagent.BrowserTool).toBeTypeOf('function');
|
||||
});
|
||||
// ============================================================
|
||||
// ToolRegistry
|
||||
// ============================================================
|
||||
|
||||
tap.test('should export DenoTool class', async () => {
|
||||
expect(smartagent.DenoTool).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// Test tool instantiation
|
||||
tap.test('should be able to instantiate FilesystemTool', async () => {
|
||||
const fsTool = new smartagent.FilesystemTool();
|
||||
expect(fsTool.name).toEqual('filesystem');
|
||||
expect(fsTool.actions).toBeTypeOf('object');
|
||||
expect(fsTool.actions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate HttpTool', async () => {
|
||||
const httpTool = new smartagent.HttpTool();
|
||||
expect(httpTool.name).toEqual('http');
|
||||
expect(httpTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate ShellTool', async () => {
|
||||
const shellTool = new smartagent.ShellTool();
|
||||
expect(shellTool.name).toEqual('shell');
|
||||
expect(shellTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate BrowserTool', async () => {
|
||||
const browserTool = new smartagent.BrowserTool();
|
||||
expect(browserTool.name).toEqual('browser');
|
||||
expect(browserTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
tap.test('should be able to instantiate DenoTool', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
expect(denoTool.name).toEqual('deno');
|
||||
expect(denoTool.actions).toBeTypeOf('object');
|
||||
});
|
||||
|
||||
// Test tool descriptions
|
||||
tap.test('FilesystemTool should have required actions', async () => {
|
||||
const fsTool = new smartagent.FilesystemTool();
|
||||
const actionNames = fsTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('read');
|
||||
expect(actionNames).toContain('write');
|
||||
expect(actionNames).toContain('list');
|
||||
expect(actionNames).toContain('delete');
|
||||
expect(actionNames).toContain('exists');
|
||||
});
|
||||
|
||||
tap.test('HttpTool should have required actions', async () => {
|
||||
const httpTool = new smartagent.HttpTool();
|
||||
const actionNames = httpTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('get');
|
||||
expect(actionNames).toContain('post');
|
||||
expect(actionNames).toContain('put');
|
||||
expect(actionNames).toContain('delete');
|
||||
});
|
||||
|
||||
tap.test('ShellTool should have required actions', async () => {
|
||||
const shellTool = new smartagent.ShellTool();
|
||||
const actionNames = shellTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('execute');
|
||||
expect(actionNames).toContain('which');
|
||||
});
|
||||
|
||||
tap.test('BrowserTool should have required actions', async () => {
|
||||
const browserTool = new smartagent.BrowserTool();
|
||||
const actionNames = browserTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('screenshot');
|
||||
expect(actionNames).toContain('pdf');
|
||||
expect(actionNames).toContain('evaluate');
|
||||
expect(actionNames).toContain('getPageContent');
|
||||
});
|
||||
|
||||
tap.test('DenoTool should have required actions', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
const actionNames = denoTool.actions.map((a) => a.name);
|
||||
expect(actionNames).toContain('execute');
|
||||
expect(actionNames).toContain('executeWithResult');
|
||||
});
|
||||
|
||||
// Test getCallSummary
|
||||
tap.test('FilesystemTool should generate call summaries', async () => {
|
||||
const fsTool = new smartagent.FilesystemTool();
|
||||
const summary = fsTool.getCallSummary('read', { path: '/tmp/test.txt' });
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('/tmp/test.txt');
|
||||
});
|
||||
|
||||
tap.test('HttpTool should generate call summaries', async () => {
|
||||
const httpTool = new smartagent.HttpTool();
|
||||
const summary = httpTool.getCallSummary('get', { url: 'https://example.com' });
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('example.com');
|
||||
});
|
||||
|
||||
tap.test('DenoTool should generate call summaries', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
const summary = denoTool.getCallSummary('execute', { code: 'console.log("hello");' });
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('sandboxed');
|
||||
});
|
||||
|
||||
tap.test('DenoTool should show permissions in call summary', async () => {
|
||||
const denoTool = new smartagent.DenoTool();
|
||||
const summary = denoTool.getCallSummary('execute', {
|
||||
code: 'console.log("hello");',
|
||||
permissions: ['net', 'read']
|
||||
tap.test('ToolRegistry should register and return tools', async () => {
|
||||
const registry = new smartagent.ToolRegistry();
|
||||
const echoTool = smartagent.tool({
|
||||
description: 'Echo tool',
|
||||
inputSchema: smartagent.z.object({ text: smartagent.z.string() }),
|
||||
execute: async ({ text }: { text: string }) => text,
|
||||
});
|
||||
expect(summary).toBeTypeOf('string');
|
||||
expect(summary).toInclude('permissions');
|
||||
expect(summary).toInclude('net');
|
||||
registry.register('echo', echoTool);
|
||||
const tools = registry.getTools();
|
||||
expect(Object.keys(tools)).toContain('echo');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Truncation
|
||||
// ============================================================
|
||||
|
||||
tap.test('truncateOutput should not truncate short strings', async () => {
|
||||
const result = truncateOutput('hello world');
|
||||
expect(result.truncated).toBeFalse();
|
||||
expect(result.content).toEqual('hello world');
|
||||
});
|
||||
|
||||
tap.test('truncateOutput should truncate strings over maxLines', async () => {
|
||||
const lines = Array.from({ length: 3000 }, (_, i) => `line ${i}`).join('\n');
|
||||
const result = truncateOutput(lines, { maxLines: 100 });
|
||||
expect(result.truncated).toBeTrue();
|
||||
expect(result.notice).toBeTruthy();
|
||||
expect(result.content).toInclude('[Output truncated');
|
||||
});
|
||||
|
||||
tap.test('truncateOutput should truncate strings over maxBytes', async () => {
|
||||
const big = 'x'.repeat(100_000);
|
||||
const result = truncateOutput(big, { maxBytes: 1000 });
|
||||
expect(result.truncated).toBeTrue();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Tool factories
|
||||
// ============================================================
|
||||
|
||||
tap.test('filesystemTool returns expected tool names', async () => {
|
||||
const tools = filesystemTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('list_directory');
|
||||
expect(names).toContain('delete_file');
|
||||
});
|
||||
|
||||
tap.test('shellTool returns expected tool names', async () => {
|
||||
const tools = shellTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('run_command');
|
||||
});
|
||||
|
||||
tap.test('httpTool returns expected tool names', async () => {
|
||||
const tools = httpTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('http_get');
|
||||
expect(names).toContain('http_post');
|
||||
});
|
||||
|
||||
tap.test('jsonTool returns expected tool names', async () => {
|
||||
const tools = jsonTool();
|
||||
const names = Object.keys(tools);
|
||||
expect(names).toContain('json_validate');
|
||||
expect(names).toContain('json_transform');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should validate valid JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
jsonString: '{"name":"test","value":42}',
|
||||
});
|
||||
expect(result).toInclude('Valid JSON');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should detect invalid JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
jsonString: '{invalid json',
|
||||
});
|
||||
expect(result).toInclude('Invalid JSON');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should check required fields', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
jsonString: '{"name":"test"}',
|
||||
requiredFields: ['name', 'missing_field'],
|
||||
});
|
||||
expect(result).toInclude('missing_field');
|
||||
});
|
||||
|
||||
tap.test('json_transform tool should pretty-print JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_transform as any).execute({
|
||||
jsonString: '{"a":1,"b":2}',
|
||||
});
|
||||
expect(result).toInclude(' "a": 1');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Compaction export
|
||||
// ============================================================
|
||||
|
||||
tap.test('compactMessages should be a function', async () => {
|
||||
expect(compactMessages).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Filesystem tool read/write round-trip
|
||||
// ============================================================
|
||||
|
||||
tap.test('filesystem tool should write and read a file', async () => {
|
||||
const tmpDir = '/tmp/smartagent-test-' + Date.now();
|
||||
const tools = filesystemTool({ rootDir: tmpDir });
|
||||
|
||||
await (tools.write_file as any).execute({
|
||||
path: tmpDir + '/hello.txt',
|
||||
content: 'Hello, world!',
|
||||
});
|
||||
|
||||
const content = await (tools.read_file as any).execute({
|
||||
path: tmpDir + '/hello.txt',
|
||||
});
|
||||
expect(content).toInclude('Hello, world!');
|
||||
|
||||
// Cleanup
|
||||
await (tools.delete_file as any).execute({
|
||||
path: tmpDir + '/hello.txt',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('filesystem tool should enforce rootDir restriction', async () => {
|
||||
const tools = filesystemTool({ rootDir: '/tmp/restricted' });
|
||||
let threw = false;
|
||||
try {
|
||||
await (tools.read_file as any).execute({ path: '/etc/passwd' });
|
||||
} catch (e) {
|
||||
threw = true;
|
||||
expect((e as Error).message).toInclude('Access denied');
|
||||
}
|
||||
expect(threw).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartagent',
|
||||
version: '1.1.1',
|
||||
description: 'an agentic framework built on top of @push.rocks/smartai'
|
||||
version: '3.0.2',
|
||||
description: 'Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.'
|
||||
}
|
||||
|
||||
39
ts/index.ts
39
ts/index.ts
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
198
ts/smartagent.classes.agent.ts
Normal file
198
ts/smartagent.classes.agent.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Retry backoff and context overflow logic derived from opencode (MIT) — https://github.com/sst/opencode
|
||||
|
||||
import * as plugins from './plugins.js';
|
||||
import type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
|
||||
import { ContextOverflowError } from './smartagent.interfaces.js';
|
||||
|
||||
// Retry constants
|
||||
const RETRY_INITIAL_DELAY = 2000;
|
||||
const RETRY_BACKOFF_FACTOR = 2;
|
||||
const RETRY_MAX_DELAY = 30_000;
|
||||
const MAX_RETRY_ATTEMPTS = 8;
|
||||
|
||||
function retryDelay(attempt: number, headers?: Record<string, string>): number {
|
||||
if (headers) {
|
||||
const ms = headers['retry-after-ms'];
|
||||
if (ms) {
|
||||
const n = parseFloat(ms);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
const after = headers['retry-after'];
|
||||
if (after) {
|
||||
const secs = parseFloat(after);
|
||||
if (!isNaN(secs)) return Math.ceil(secs * 1000);
|
||||
const date = Date.parse(after) - Date.now();
|
||||
if (!isNaN(date) && date > 0) return Math.ceil(date);
|
||||
}
|
||||
}
|
||||
return Math.min(
|
||||
RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1),
|
||||
RETRY_MAX_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isRetryableError(err: unknown): boolean {
|
||||
const status = (err as any)?.status ?? (err as any)?.statusCode;
|
||||
if (status === 429 || status === 529 || status === 503) return true;
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes('rate limit') || msg.includes('overloaded') || msg.includes('too many requests')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isContextOverflow(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('context_length_exceeded') ||
|
||||
msg.includes('context window') ||
|
||||
msg.includes('maximum context length') ||
|
||||
msg.includes('too many tokens') ||
|
||||
msg.includes('input is too long') ||
|
||||
(err as any)?.name === 'AI_ContextWindowExceededError'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResult> {
|
||||
let stepCount = 0;
|
||||
let attempt = 0;
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
|
||||
const tools = options.tools ?? {};
|
||||
|
||||
// Add a no-op sink for repaired-but-unrecognised tool calls
|
||||
const allTools: plugins.ToolSet = {
|
||||
...tools,
|
||||
invalid: plugins.tool({
|
||||
description: 'Sink for unrecognised tool calls — returns an error message to the model',
|
||||
inputSchema: plugins.z.object({
|
||||
tool: plugins.z.string(),
|
||||
error: plugins.z.string(),
|
||||
}),
|
||||
execute: async ({ tool, error }: { tool: string; error: string }) =>
|
||||
`Unknown tool "${tool}": ${error}`,
|
||||
}),
|
||||
};
|
||||
|
||||
// Build messages — streamText requires either prompt OR messages, not both
|
||||
let messages: plugins.ModelMessage[] = options.messages
|
||||
? [...options.messages, { role: 'user' as const, content: options.prompt }]
|
||||
: [{ role: 'user' as const, content: options.prompt }];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = plugins.streamText({
|
||||
model: options.model,
|
||||
system: options.system,
|
||||
messages,
|
||||
tools: allTools,
|
||||
stopWhen: plugins.stepCountIs(options.maxSteps ?? 20),
|
||||
maxRetries: 0, // handled manually below
|
||||
abortSignal: options.abort,
|
||||
|
||||
experimental_repairToolCall: async ({ toolCall, tools: availableTools, error }) => {
|
||||
const lower = toolCall.toolName.toLowerCase();
|
||||
if (lower !== toolCall.toolName && (availableTools as any)[lower]) {
|
||||
return { ...toolCall, toolName: lower };
|
||||
}
|
||||
return {
|
||||
...toolCall,
|
||||
toolName: 'invalid',
|
||||
args: JSON.stringify({
|
||||
tool: toolCall.toolName,
|
||||
error: String(error),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
onChunk: ({ chunk }) => {
|
||||
if (chunk.type === 'text-delta' && options.onToken) {
|
||||
options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? '');
|
||||
}
|
||||
},
|
||||
|
||||
experimental_onToolCallStart: options.onToolCall
|
||||
? ({ toolCall }) => {
|
||||
options.onToolCall!(toolCall.toolName, (toolCall as any).input ?? (toolCall as any).args);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
experimental_onToolCallFinish: options.onToolResult
|
||||
? ({ toolCall, output }) => {
|
||||
options.onToolResult!(toolCall.toolName, output);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
onStepFinish: ({ usage }) => {
|
||||
stepCount++;
|
||||
totalInput += usage?.inputTokens ?? 0;
|
||||
totalOutput += usage?.outputTokens ?? 0;
|
||||
},
|
||||
});
|
||||
|
||||
// Consume the stream and collect results
|
||||
const text = await result.text;
|
||||
const finishReason = await result.finishReason;
|
||||
const responseData = await result.response;
|
||||
|
||||
attempt = 0; // reset on success
|
||||
|
||||
return {
|
||||
text,
|
||||
messages: responseData.messages as plugins.ModelMessage[],
|
||||
steps: stepCount,
|
||||
finishReason,
|
||||
usage: {
|
||||
inputTokens: totalInput,
|
||||
outputTokens: totalOutput,
|
||||
totalTokens: totalInput + totalOutput,
|
||||
},
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// Abort — don't retry
|
||||
if (err instanceof DOMException && err.name === 'AbortError') throw err;
|
||||
|
||||
// Rate limit / overload — retry with backoff
|
||||
if (isRetryableError(err) && attempt < MAX_RETRY_ATTEMPTS) {
|
||||
attempt++;
|
||||
const headers = (err as any)?.responseHeaders ?? (err as any)?.headers;
|
||||
const delay = retryDelay(attempt, headers);
|
||||
await sleep(delay, options.abort);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Context overflow — compact and retry if handler provided
|
||||
if (isContextOverflow(err)) {
|
||||
if (!options.onContextOverflow) throw new ContextOverflowError();
|
||||
messages = await options.onContextOverflow(messages);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import type { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* GuardianAgent - Evaluates tool call proposals against a policy
|
||||
* Uses AI reasoning to approve or reject tool calls
|
||||
*/
|
||||
export class GuardianAgent {
|
||||
private provider: plugins.smartai.MultiModalModel;
|
||||
private policyPrompt: string;
|
||||
private tools: Map<string, BaseToolWrapper> = new Map();
|
||||
|
||||
constructor(
|
||||
provider: plugins.smartai.MultiModalModel,
|
||||
policyPrompt: string
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.policyPrompt = policyPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool for reference during evaluation
|
||||
*/
|
||||
public registerTool(tool: BaseToolWrapper): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a tool call proposal against the policy
|
||||
*/
|
||||
public async evaluate(
|
||||
proposal: interfaces.IToolCallProposal,
|
||||
taskContext: string
|
||||
): Promise<interfaces.IGuardianDecision> {
|
||||
// Get the tool to generate a human-readable summary
|
||||
const tool = this.tools.get(proposal.toolName);
|
||||
let callSummary = `${proposal.toolName}.${proposal.action}(${JSON.stringify(proposal.params)})`;
|
||||
|
||||
if (tool) {
|
||||
try {
|
||||
callSummary = tool.getCallSummary(proposal.action, proposal.params);
|
||||
} catch {
|
||||
// Fallback to basic summary
|
||||
}
|
||||
}
|
||||
|
||||
// Build the evaluation prompt
|
||||
const evaluationPrompt = this.buildEvaluationPrompt(
|
||||
proposal,
|
||||
callSummary,
|
||||
taskContext
|
||||
);
|
||||
|
||||
// Get response from provider
|
||||
const response = await this.provider.chat({
|
||||
systemMessage: this.buildGuardianSystemMessage(),
|
||||
userMessage: evaluationPrompt,
|
||||
messageHistory: [],
|
||||
});
|
||||
|
||||
// Parse the decision from the response
|
||||
return this.parseDecision(response.message, proposal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the system message for the Guardian
|
||||
*/
|
||||
private buildGuardianSystemMessage(): string {
|
||||
return `You are a Guardian AI responsible for evaluating tool call proposals.
|
||||
|
||||
## Your Role
|
||||
You evaluate whether proposed tool calls are safe and aligned with the policy.
|
||||
|
||||
## Policy to Enforce
|
||||
${this.policyPrompt}
|
||||
|
||||
## Response Format
|
||||
For EVERY evaluation, respond with a decision in this exact format:
|
||||
|
||||
<guardian_decision>
|
||||
<decision>approve OR reject</decision>
|
||||
<reason>Your detailed explanation</reason>
|
||||
<concerns>List any concerns, even if approving</concerns>
|
||||
<suggestions>Alternative approaches if rejecting</suggestions>
|
||||
</guardian_decision>
|
||||
|
||||
## Guidelines
|
||||
1. Carefully analyze what the tool call will do
|
||||
2. Consider security implications
|
||||
3. Check against the policy requirements
|
||||
4. If uncertain, err on the side of caution (reject)
|
||||
5. Provide actionable feedback when rejecting`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the evaluation prompt for a specific proposal
|
||||
*/
|
||||
private buildEvaluationPrompt(
|
||||
proposal: interfaces.IToolCallProposal,
|
||||
callSummary: string,
|
||||
taskContext: string
|
||||
): string {
|
||||
const toolInfo = this.tools.get(proposal.toolName);
|
||||
const toolDescription = toolInfo ? toolInfo.getFullDescription() : 'Unknown tool';
|
||||
|
||||
return `## Task Context
|
||||
${taskContext}
|
||||
|
||||
## Tool Being Used
|
||||
${toolDescription}
|
||||
|
||||
## Proposed Tool Call
|
||||
- **Tool**: ${proposal.toolName}
|
||||
- **Action**: ${proposal.action}
|
||||
- **Parameters**: ${JSON.stringify(proposal.params, null, 2)}
|
||||
|
||||
## Human-Readable Summary
|
||||
${callSummary}
|
||||
|
||||
## Driver's Reasoning
|
||||
${proposal.reasoning || 'No reasoning provided'}
|
||||
|
||||
---
|
||||
|
||||
Evaluate this tool call against the policy. Should it be approved or rejected?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the guardian decision from the response
|
||||
*/
|
||||
private parseDecision(
|
||||
response: string,
|
||||
proposal: interfaces.IToolCallProposal
|
||||
): interfaces.IGuardianDecision {
|
||||
// Try to extract from XML tags
|
||||
const decisionMatch = response.match(/<decision>(.*?)<\/decision>/s);
|
||||
const reasonMatch = response.match(/<reason>([\s\S]*?)<\/reason>/);
|
||||
const concernsMatch = response.match(/<concerns>([\s\S]*?)<\/concerns>/);
|
||||
const suggestionsMatch = response.match(/<suggestions>([\s\S]*?)<\/suggestions>/);
|
||||
|
||||
// Determine decision
|
||||
let decision: 'approve' | 'reject' = 'reject';
|
||||
if (decisionMatch) {
|
||||
const decisionText = decisionMatch[1].trim().toLowerCase();
|
||||
decision = decisionText.includes('approve') ? 'approve' : 'reject';
|
||||
} else {
|
||||
// Fallback: look for approval keywords in the response
|
||||
const lowerResponse = response.toLowerCase();
|
||||
if (
|
||||
lowerResponse.includes('approved') ||
|
||||
lowerResponse.includes('i approve') ||
|
||||
lowerResponse.includes('looks safe')
|
||||
) {
|
||||
decision = 'approve';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reason
|
||||
let reason = reasonMatch ? reasonMatch[1].trim() : '';
|
||||
if (!reason) {
|
||||
// Use the full response as reason if no tag found
|
||||
reason = response.substring(0, 500);
|
||||
}
|
||||
|
||||
// Extract concerns
|
||||
const concerns: string[] = [];
|
||||
if (concernsMatch) {
|
||||
const concernsText = concernsMatch[1].trim();
|
||||
if (concernsText && concernsText.toLowerCase() !== 'none') {
|
||||
// Split by newlines or bullet points
|
||||
const concernLines = concernsText.split(/[\n\r]+/).map(l => l.trim()).filter(l => l);
|
||||
concerns.push(...concernLines);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract suggestions
|
||||
const suggestions = suggestionsMatch ? suggestionsMatch[1].trim() : undefined;
|
||||
|
||||
return {
|
||||
decision,
|
||||
reason,
|
||||
concerns: concerns.length > 0 ? concerns : undefined,
|
||||
suggestions: suggestions && suggestions.toLowerCase() !== 'none' ? suggestions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation without AI (for obviously safe/unsafe operations)
|
||||
* Returns null if AI evaluation is needed
|
||||
*/
|
||||
public quickValidate(proposal: interfaces.IToolCallProposal): interfaces.IGuardianDecision | null {
|
||||
// Check if tool exists
|
||||
if (!this.tools.has(proposal.toolName)) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
reason: `Unknown tool: ${proposal.toolName}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if action exists
|
||||
const tool = this.tools.get(proposal.toolName)!;
|
||||
const validAction = tool.actions.find(a => a.name === proposal.action);
|
||||
if (!validAction) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
reason: `Unknown action "${proposal.action}" for tool "${proposal.toolName}". Available actions: ${tool.actions.map(a => a.name).join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check required parameters
|
||||
const schema = validAction.parameters;
|
||||
if (schema && schema.required && Array.isArray(schema.required)) {
|
||||
for (const requiredParam of schema.required as string[]) {
|
||||
if (!(requiredParam in proposal.params)) {
|
||||
return {
|
||||
decision: 'reject',
|
||||
reason: `Missing required parameter: ${requiredParam}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Needs full AI evaluation
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the policy prompt
|
||||
*/
|
||||
public setPolicy(policyPrompt: string): void {
|
||||
this.policyPrompt = policyPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current policy
|
||||
*/
|
||||
public getPolicy(): string {
|
||||
return this.policyPrompt;
|
||||
}
|
||||
}
|
||||
20
ts/smartagent.classes.toolregistry.ts
Normal file
20
ts/smartagent.classes.toolregistry.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
ts/smartagent.utils.truncation.ts
Normal file
39
ts/smartagent.utils.truncation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Truncation logic derived from opencode (MIT) — https://github.com/sst/opencode
|
||||
|
||||
const MAX_LINES = 2000;
|
||||
const MAX_BYTES = 50 * 1024; // 50 KB
|
||||
|
||||
export interface ITruncateResult {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
/** Set when truncated: describes what was dropped */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function truncateOutput(
|
||||
text: string,
|
||||
options?: { maxLines?: number; maxBytes?: number },
|
||||
): ITruncateResult {
|
||||
const maxLines = options?.maxLines ?? MAX_LINES;
|
||||
const maxBytes = options?.maxBytes ?? MAX_BYTES;
|
||||
const lines = text.split('\n');
|
||||
const totalBytes = Buffer.byteLength(text, 'utf-8');
|
||||
|
||||
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
||||
return { content: text, truncated: false };
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
let bytes = 0;
|
||||
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
||||
const size = Buffer.byteLength(lines[i], 'utf-8') + (i > 0 ? 1 : 0);
|
||||
if (bytes + size > maxBytes) break;
|
||||
out.push(lines[i]);
|
||||
bytes += size;
|
||||
}
|
||||
|
||||
const kept = out.length;
|
||||
const dropped = lines.length - kept;
|
||||
const notice = `\n[Output truncated: showing ${kept}/${lines.length} lines. ${dropped} lines omitted.]`;
|
||||
return { content: out.join('\n') + notice, truncated: true, notice };
|
||||
}
|
||||
1
ts_compaction/index.ts
Normal file
1
ts_compaction/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { compactMessages } from './smartagent.compaction.js';
|
||||
6
ts_compaction/plugins.ts
Normal file
6
ts_compaction/plugins.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { generateText } from 'ai';
|
||||
|
||||
export { generateText };
|
||||
|
||||
export type { ModelMessage } from 'ai';
|
||||
export type { LanguageModelV3 } from '@push.rocks/smartai';
|
||||
51
ts_compaction/smartagent.compaction.ts
Normal file
51
ts_compaction/smartagent.compaction.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
const COMPACTION_PROMPT = `Provide a detailed prompt for continuing our conversation above.
|
||||
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
|
||||
The summary that you construct will be used so that another agent can read it and continue the work.
|
||||
|
||||
When constructing the summary, try to stick to this template:
|
||||
---
|
||||
## Goal
|
||||
[What goal(s) is the user trying to accomplish?]
|
||||
|
||||
## Instructions
|
||||
- [What important instructions did the user give you that are relevant]
|
||||
|
||||
## Discoveries
|
||||
[What notable things were learned during this conversation that would be useful for the next agent to know]
|
||||
|
||||
## Accomplished
|
||||
[What work has been completed, what work is still in progress, and what work is left?]
|
||||
|
||||
## Relevant files / directories
|
||||
[A structured list of relevant files that have been read, edited, or created]
|
||||
---`;
|
||||
|
||||
/**
|
||||
* Compacts a message history into a summary.
|
||||
* Pass this as the onContextOverflow handler in IAgentRunOptions.
|
||||
*
|
||||
* @param model The same model used by runAgent, or a cheaper small model
|
||||
* @param messages The full message history that overflowed
|
||||
* @returns A minimal ModelMessage[] containing the summary as context
|
||||
*/
|
||||
export async function compactMessages(
|
||||
model: plugins.LanguageModelV3,
|
||||
messages: plugins.ModelMessage[],
|
||||
): Promise<plugins.ModelMessage[]> {
|
||||
const result = await plugins.generateText({
|
||||
model,
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'user', content: COMPACTION_PROMPT },
|
||||
],
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
role: 'user',
|
||||
content: `[Previous conversation summary]\n\n${result.text}\n\n[End of summary. Continue from here.]`,
|
||||
},
|
||||
];
|
||||
}
|
||||
8
ts_tools/index.ts
Normal file
8
ts_tools/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { filesystemTool } from './tool.filesystem.js';
|
||||
export type { IFilesystemToolOptions } from './tool.filesystem.js';
|
||||
export { shellTool } from './tool.shell.js';
|
||||
export type { IShellToolOptions } from './tool.shell.js';
|
||||
export { httpTool } from './tool.http.js';
|
||||
export { jsonTool } from './tool.json.js';
|
||||
export { truncateOutput } from './plugins.js';
|
||||
export type { ITruncateResult } from './plugins.js';
|
||||
30
ts_tools/plugins.ts
Normal file
30
ts_tools/plugins.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// node native
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export { path, fs };
|
||||
|
||||
// zod
|
||||
import { z } from 'zod';
|
||||
|
||||
export { z };
|
||||
|
||||
// ai-sdk
|
||||
import { tool } from '@push.rocks/smartai';
|
||||
|
||||
export { tool };
|
||||
|
||||
export type { ToolSet } from 'ai';
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
|
||||
export { smartfs, smartshell, smartrequest };
|
||||
|
||||
// cross-folder import
|
||||
import { truncateOutput } from '../ts/smartagent.utils.truncation.js';
|
||||
|
||||
export { truncateOutput };
|
||||
export type { ITruncateResult } from '../ts/smartagent.utils.truncation.js';
|
||||
131
ts_tools/tool.filesystem.ts
Normal file
131
ts_tools/tool.filesystem.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IFilesystemToolOptions {
|
||||
/** Restrict file access to this directory. Default: process.cwd() */
|
||||
rootDir?: string;
|
||||
}
|
||||
|
||||
function validatePath(filePath: string, rootDir?: string): string {
|
||||
const resolved = plugins.path.resolve(filePath);
|
||||
if (rootDir) {
|
||||
const resolvedRoot = plugins.path.resolve(rootDir);
|
||||
if (!resolved.startsWith(resolvedRoot + plugins.path.sep) && resolved !== resolvedRoot) {
|
||||
throw new Error(`Access denied: "${filePath}" is outside allowed root "${rootDir}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
|
||||
const rootDir = options?.rootDir;
|
||||
|
||||
return {
|
||||
read_file: plugins.tool({
|
||||
description:
|
||||
'Read file contents. Returns the full text or a specified line range.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
startLine: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('First line (1-indexed, inclusive)'),
|
||||
endLine: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Last line (1-indexed, inclusive)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
}: {
|
||||
path: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const content = plugins.fs.readFileSync(resolved, 'utf-8');
|
||||
|
||||
if (startLine !== undefined || endLine !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const start = (startLine ?? 1) - 1;
|
||||
const end = endLine ?? lines.length;
|
||||
const sliced = lines.slice(start, end).join('\n');
|
||||
return plugins.truncateOutput(sliced).content;
|
||||
}
|
||||
|
||||
return plugins.truncateOutput(content).content;
|
||||
},
|
||||
}),
|
||||
|
||||
write_file: plugins.tool({
|
||||
description:
|
||||
'Write content to a file (creates parent dirs if needed, overwrites existing).',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
content: plugins.z.string().describe('Content to write'),
|
||||
}),
|
||||
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const dir = plugins.path.dirname(resolved);
|
||||
plugins.fs.mkdirSync(dir, { recursive: true });
|
||||
plugins.fs.writeFileSync(resolved, content, 'utf-8');
|
||||
return `Written ${content.length} characters to ${filePath}`;
|
||||
},
|
||||
}),
|
||||
|
||||
list_directory: plugins.tool({
|
||||
description: 'List files and directories at the given path.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Directory path to list'),
|
||||
recursive: plugins.z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('List recursively (default: false)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: dirPath,
|
||||
recursive,
|
||||
}: {
|
||||
path: string;
|
||||
recursive?: boolean;
|
||||
}) => {
|
||||
const resolved = validatePath(dirPath, rootDir);
|
||||
|
||||
function listDir(dir: string, prefix: string = ''): string[] {
|
||||
const entries = plugins.fs.readdirSync(dir, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const indicator = entry.isDirectory() ? '/' : '';
|
||||
result.push(`${rel}${indicator}`);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
result.push(...listDir(plugins.path.join(dir, entry.name), rel));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const entries = listDir(resolved);
|
||||
return plugins.truncateOutput(entries.join('\n')).content;
|
||||
},
|
||||
}),
|
||||
|
||||
delete_file: plugins.tool({
|
||||
description: 'Delete a file or empty directory.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Path to delete'),
|
||||
}),
|
||||
execute: async ({ path: filePath }: { path: string }) => {
|
||||
const resolved = validatePath(filePath, rootDir);
|
||||
const stat = plugins.fs.statSync(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
plugins.fs.rmdirSync(resolved);
|
||||
} else {
|
||||
plugins.fs.unlinkSync(resolved);
|
||||
}
|
||||
return `Deleted ${filePath}`;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
78
ts_tools/tool.http.ts
Normal file
78
ts_tools/tool.http.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export function httpTool(): plugins.ToolSet {
|
||||
return {
|
||||
http_get: plugins.tool({
|
||||
description: 'Make an HTTP GET request and return the response.',
|
||||
inputSchema: plugins.z.object({
|
||||
url: plugins.z.string().describe('URL to request'),
|
||||
headers: plugins.z
|
||||
.record(plugins.z.string())
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
}),
|
||||
execute: async ({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}) => {
|
||||
let req = plugins.smartrequest.default.create().url(url);
|
||||
if (headers) {
|
||||
req = req.headers(headers);
|
||||
}
|
||||
const response = await req.get();
|
||||
let body: string;
|
||||
try {
|
||||
const json = await response.json();
|
||||
body = JSON.stringify(json, null, 2);
|
||||
} catch {
|
||||
body = await response.text();
|
||||
}
|
||||
return plugins.truncateOutput(`HTTP ${response.status}\n${body}`).content;
|
||||
},
|
||||
}),
|
||||
|
||||
http_post: plugins.tool({
|
||||
description: 'Make an HTTP POST request with a JSON body.',
|
||||
inputSchema: plugins.z.object({
|
||||
url: plugins.z.string().describe('URL to request'),
|
||||
body: plugins.z
|
||||
.record(plugins.z.unknown())
|
||||
.optional()
|
||||
.describe('JSON body to send'),
|
||||
headers: plugins.z
|
||||
.record(plugins.z.string())
|
||||
.optional()
|
||||
.describe('Request headers'),
|
||||
}),
|
||||
execute: async ({
|
||||
url,
|
||||
body,
|
||||
headers,
|
||||
}: {
|
||||
url: string;
|
||||
body?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
}) => {
|
||||
let req = plugins.smartrequest.default.create().url(url);
|
||||
if (headers) {
|
||||
req = req.headers(headers);
|
||||
}
|
||||
if (body) {
|
||||
req = req.json(body);
|
||||
}
|
||||
const response = await req.post();
|
||||
let responseBody: string;
|
||||
try {
|
||||
const json = await response.json();
|
||||
responseBody = JSON.stringify(json, null, 2);
|
||||
} catch {
|
||||
responseBody = await response.text();
|
||||
}
|
||||
return plugins.truncateOutput(`HTTP ${response.status}\n${responseBody}`).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
53
ts_tools/tool.json.ts
Normal file
53
ts_tools/tool.json.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export function jsonTool(): plugins.ToolSet {
|
||||
return {
|
||||
json_validate: plugins.tool({
|
||||
description:
|
||||
'Validate a JSON string and optionally check for required fields.',
|
||||
inputSchema: plugins.z.object({
|
||||
jsonString: plugins.z.string().describe('JSON string to validate'),
|
||||
requiredFields: plugins.z
|
||||
.array(plugins.z.string())
|
||||
.optional()
|
||||
.describe('Fields that must exist at the top level'),
|
||||
}),
|
||||
execute: async ({
|
||||
jsonString,
|
||||
requiredFields,
|
||||
}: {
|
||||
jsonString: string;
|
||||
requiredFields?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
if (requiredFields?.length) {
|
||||
const missing = requiredFields.filter((f) => !(f in parsed));
|
||||
if (missing.length) {
|
||||
return `Invalid: missing required fields: ${missing.join(', ')}`;
|
||||
}
|
||||
}
|
||||
const type = Array.isArray(parsed) ? 'array' : typeof parsed;
|
||||
return `Valid JSON (${type})`;
|
||||
} catch (e) {
|
||||
return `Invalid JSON: ${(e as Error).message}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
json_transform: plugins.tool({
|
||||
description: 'Parse a JSON string and return it pretty-printed.',
|
||||
inputSchema: plugins.z.object({
|
||||
jsonString: plugins.z.string().describe('JSON string to format'),
|
||||
}),
|
||||
execute: async ({ jsonString }: { jsonString: string }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
return `Error parsing JSON: ${(e as Error).message}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
62
ts_tools/tool.shell.ts
Normal file
62
ts_tools/tool.shell.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IShellToolOptions {
|
||||
/** Allowed commands whitelist. If empty, all commands are allowed. */
|
||||
allowedCommands?: string[];
|
||||
/** Working directory for shell execution */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
const smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
|
||||
return {
|
||||
run_command: plugins.tool({
|
||||
description:
|
||||
'Execute a shell command. Provide the full command string. stdout and stderr are returned.',
|
||||
inputSchema: plugins.z.object({
|
||||
command: plugins.z.string().describe('The shell command to execute'),
|
||||
cwd: plugins.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Working directory for the command'),
|
||||
timeout: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timeout in milliseconds'),
|
||||
}),
|
||||
execute: async ({
|
||||
command,
|
||||
cwd,
|
||||
timeout,
|
||||
}: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
}) => {
|
||||
// Validate against allowed commands whitelist
|
||||
if (options?.allowedCommands?.length) {
|
||||
const baseCommand = command.split(/\s+/)[0];
|
||||
if (!options.allowedCommands.includes(baseCommand)) {
|
||||
return `Command "${baseCommand}" is not in the allowed commands list: ${options.allowedCommands.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build full command string with cd prefix if cwd specified
|
||||
const effectiveCwd = cwd ?? options?.cwd;
|
||||
const fullCommand = effectiveCwd
|
||||
? `cd ${JSON.stringify(effectiveCwd)} && ${command}`
|
||||
: command;
|
||||
|
||||
const execResult = await smartshell.exec(fullCommand);
|
||||
|
||||
const output =
|
||||
execResult.exitCode === 0
|
||||
? execResult.stdout
|
||||
: `Exit code: ${execResult.exitCode}\nstdout:\n${execResult.stdout}\nstderr:\n${execResult.stderr ?? ''}`;
|
||||
|
||||
return plugins.truncateOutput(output).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user