Compare commits
46 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 |
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"
|
||||
|
||||
48
package.json
48
package.json
@@ -1,33 +1,47 @@
|
||||
{
|
||||
"name": "@push.rocks/smartagent",
|
||||
"version": "1.2.5",
|
||||
"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",
|
||||
"minimatch": "^10.1.1"
|
||||
"@push.rocks/smartshell": "^3.3.7",
|
||||
"ai": "^6.0.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"repository": {
|
||||
@@ -40,13 +54,11 @@
|
||||
"homepage": "https://code.foss.global/push.rocks/smartagent#readme",
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_tools/**/*",
|
||||
"ts_compaction/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
],
|
||||
|
||||
3161
pnpm-lock.yaml
generated
3161
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`
|
||||
|
||||
768
readme.md
768
readme.md
@@ -1,12 +1,10 @@
|
||||
# @push.rocks/smartagent
|
||||
|
||||
A dual-agent agentic framework with **Driver** and **Guardian** agents for safe, policy-controlled AI task execution. 🤖🛡️
|
||||
A lightweight agentic loop built on **Vercel AI SDK v6** via `@push.rocks/smartai`. Register tools, get a model, call `runAgent()` — done. 🚀
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @push.rocks/smartagent
|
||||
# or
|
||||
pnpm install @push.rocks/smartagent
|
||||
```
|
||||
|
||||
@@ -16,459 +14,363 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
|
||||
## Overview
|
||||
|
||||
SmartAgent implements a **dual-agent architecture** where AI safety isn't just an afterthought—it's baked into the core design:
|
||||
`@push.rocks/smartagent` wraps the AI SDK's `streamText` with `stopWhen: stepCountIs(n)` for **parallel multi-step tool execution**. No classes to instantiate, no lifecycle to manage — just one async function:
|
||||
|
||||
- **🎯 Driver Agent**: The executor. Reasons about goals, plans steps, and proposes tool calls
|
||||
- **🛡️ Guardian Agent**: The gatekeeper. Evaluates every tool call against your policy, approving or rejecting with feedback
|
||||
```typescript
|
||||
import { runAgent, tool, z } from '@push.rocks/smartagent';
|
||||
import { getModel } from '@push.rocks/smartai';
|
||||
|
||||
This design ensures safe tool use through **AI-based policy evaluation** rather than rigid programmatic rules. The Guardian can understand context, nuance, and intent—catching dangerous operations that simple regex or allowlists would miss.
|
||||
const model = getModel({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
apiKey: process.env.ANTHROPIC_TOKEN,
|
||||
});
|
||||
|
||||
### Why Dual-Agent?
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'What is 7 + 35?',
|
||||
system: 'You are a helpful assistant. Use tools when asked.',
|
||||
tools: {
|
||||
calculator: tool({
|
||||
description: 'Perform arithmetic',
|
||||
inputSchema: z.object({
|
||||
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
|
||||
a: z.number(),
|
||||
b: z.number(),
|
||||
}),
|
||||
execute: async ({ operation, a, b }) => {
|
||||
const ops = { add: a + b, subtract: a - b, multiply: a * b, divide: a / b };
|
||||
return String(ops[operation]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
maxSteps: 10,
|
||||
});
|
||||
|
||||
Traditional AI agents have a fundamental problem: they're given tools and expected to use them responsibly. SmartAgent adds a second AI specifically trained to evaluate whether each action is safe and appropriate. Think of it as separation of concerns, but for AI safety.
|
||||
console.log(result.text); // "7 + 35 = 42"
|
||||
console.log(result.steps); // number of agentic steps taken
|
||||
console.log(result.usage); // { promptTokens, completionTokens, totalTokens }
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Input
|
||||
Task["User Task"]
|
||||
Policy["Guardian Policy Prompt"]
|
||||
end
|
||||
|
||||
subgraph Orchestrator["DualAgentOrchestrator"]
|
||||
Driver["Driver Agent<br/><i>Reason + Plan</i>"]
|
||||
Guardian["Guardian Agent<br/><i>Evaluate against policy</i>"]
|
||||
|
||||
Driver -->|"tool call proposal"| Guardian
|
||||
Guardian -->|"approve / reject + feedback"| Driver
|
||||
end
|
||||
|
||||
subgraph Tools["Standard Tools"]
|
||||
FS["Filesystem"]
|
||||
HTTP["HTTP"]
|
||||
Shell["Shell"]
|
||||
Browser["Browser"]
|
||||
Deno["Deno"]
|
||||
end
|
||||
|
||||
Task --> Orchestrator
|
||||
Policy --> Guardian
|
||||
Driver -->|"execute<br/>(if approved)"| Tools
|
||||
Tools -->|"result"| Driver
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ runAgent({ model, prompt, tools, maxSteps }) │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ Messages │──▶│ streamText│──▶│ Tools │ │
|
||||
│ │ (history) │◀──│ (AI SDK) │◀──│ (ToolSet) │ │
|
||||
│ └────────────┘ └───────────┘ └───────────┘ │
|
||||
│ │
|
||||
│ stopWhen: stepCountIs(maxSteps) │
|
||||
│ + retry with backoff on 429/529/503 │
|
||||
│ + context overflow detection & recovery │
|
||||
│ + tool call repair (case-insensitive matching) │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
**Key features:**
|
||||
|
||||
- 🔄 **Multi-step agentic loop** — the model calls tools, sees results, and continues reasoning until done
|
||||
- ⚡ **Parallel tool execution** — multiple tool calls in a single step are executed concurrently
|
||||
- 🔧 **Auto-retry with backoff** — handles 429/529/503 errors with header-aware retry delays
|
||||
- 🩹 **Tool call repair** — case-insensitive name matching + invalid tool sink prevents crashes
|
||||
- 📊 **Token streaming** — `onToken` and `onToolCall` callbacks for real-time progress
|
||||
- 💥 **Context overflow handling** — detects overflow and invokes your `onContextOverflow` callback
|
||||
|
||||
## Core API
|
||||
|
||||
### `runAgent(options): Promise<IAgentRunResult>`
|
||||
|
||||
The single entry point. Options:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `model` | `LanguageModelV3` | *required* | Model from `@push.rocks/smartai`'s `getModel()` |
|
||||
| `prompt` | `string` | *required* | The user's task/question |
|
||||
| `system` | `string` | `undefined` | System prompt |
|
||||
| `tools` | `ToolSet` | `{}` | Tools the agent can call |
|
||||
| `maxSteps` | `number` | `20` | Max agentic steps before stopping |
|
||||
| `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) |
|
||||
| `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors |
|
||||
| `onToken` | `(delta: string) => void` | — | Streaming token callback |
|
||||
| `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked |
|
||||
| `onContextOverflow` | `(messages) => messages` | — | Handle context overflow (e.g., compact messages) |
|
||||
|
||||
### `IAgentRunResult`
|
||||
|
||||
```typescript
|
||||
import { DualAgentOrchestrator } from '@push.rocks/smartagent';
|
||||
|
||||
// Create orchestrator with Guardian policy
|
||||
const orchestrator = new DualAgentOrchestrator({
|
||||
openaiToken: 'sk-...',
|
||||
defaultProvider: 'openai',
|
||||
guardianPolicyPrompt: `
|
||||
FILE SYSTEM POLICY:
|
||||
- ONLY allow reading/writing within /tmp or the current working directory
|
||||
- REJECT operations on system directories or sensitive files
|
||||
|
||||
SHELL POLICY:
|
||||
- Allow read-only commands (ls, cat, grep, echo)
|
||||
- REJECT destructive commands (rm, mv, chmod) without explicit justification
|
||||
|
||||
FLAG any attempt to expose secrets or credentials.
|
||||
`,
|
||||
});
|
||||
|
||||
// Register standard tools
|
||||
orchestrator.registerStandardTools();
|
||||
|
||||
// Start the orchestrator (initializes all tools)
|
||||
await orchestrator.start();
|
||||
|
||||
// Run a task
|
||||
const result = await orchestrator.run('List all TypeScript files in the current directory');
|
||||
|
||||
console.log('Success:', result.success);
|
||||
console.log('Result:', result.result);
|
||||
console.log('Iterations:', result.iterations);
|
||||
|
||||
// Cleanup
|
||||
await orchestrator.stop();
|
||||
```
|
||||
|
||||
## Standard Tools
|
||||
|
||||
SmartAgent comes with five battle-tested tools out of the box:
|
||||
|
||||
### 🗂️ FilesystemTool
|
||||
|
||||
File and directory operations powered by `@push.rocks/smartfs`.
|
||||
|
||||
**Actions**: `read`, `write`, `append`, `list`, `delete`, `exists`, `stat`, `copy`, `move`, `mkdir`
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
**Scoped Filesystem**: Lock file operations to a specific directory:
|
||||
|
||||
```typescript
|
||||
// Only allow access within a specific directory
|
||||
orchestrator.registerScopedFilesystemTool('/home/user/workspace');
|
||||
```
|
||||
|
||||
### 🌐 HttpTool
|
||||
|
||||
HTTP requests using `@push.rocks/smartrequest`.
|
||||
|
||||
**Actions**: `get`, `post`, `put`, `patch`, `delete`
|
||||
|
||||
```typescript
|
||||
<tool_call>
|
||||
<tool>http</tool>
|
||||
<action>get</action>
|
||||
<params>{"url": "https://api.example.com/data", "headers": {"Authorization": "Bearer token"}}</params>
|
||||
<reasoning>Fetching data from the API endpoint</reasoning>
|
||||
</tool_call>
|
||||
```
|
||||
|
||||
### 💻 ShellTool
|
||||
|
||||
Secure shell command execution using `@push.rocks/smartshell` with `execSpawn` (no shell injection possible).
|
||||
|
||||
**Actions**: `execute`, `which`
|
||||
|
||||
```typescript
|
||||
<tool_call>
|
||||
<tool>shell</tool>
|
||||
<action>execute</action>
|
||||
<params>{"command": "ls", "args": ["-la", "/tmp"]}</params>
|
||||
<reasoning>Listing directory contents to find relevant files</reasoning>
|
||||
</tool_call>
|
||||
```
|
||||
|
||||
> 🔒 **Security Note**: The shell tool uses `execSpawn` with `shell: false`, meaning command and arguments are passed separately. This makes shell injection attacks impossible.
|
||||
|
||||
### 🌍 BrowserTool
|
||||
|
||||
Web page interaction using `@push.rocks/smartbrowser` (Puppeteer-based).
|
||||
|
||||
**Actions**: `screenshot`, `pdf`, `evaluate`, `getPageContent`
|
||||
|
||||
```typescript
|
||||
<tool_call>
|
||||
<tool>browser</tool>
|
||||
<action>getPageContent</action>
|
||||
<params>{"url": "https://example.com"}</params>
|
||||
<reasoning>Extracting text content from the webpage</reasoning>
|
||||
</tool_call>
|
||||
```
|
||||
|
||||
### 🦕 DenoTool
|
||||
|
||||
Execute TypeScript/JavaScript code in a **sandboxed Deno environment** with fine-grained permission control.
|
||||
|
||||
**Actions**: `execute`, `executeWithResult`
|
||||
|
||||
**Permissions**: `all`, `env`, `ffi`, `hrtime`, `net`, `read`, `run`, `sys`, `write`
|
||||
|
||||
By default, code runs **fully sandboxed with no permissions**. Permissions must be explicitly requested and are subject to Guardian approval.
|
||||
|
||||
```typescript
|
||||
// Simple code execution (sandboxed, no permissions)
|
||||
<tool_call>
|
||||
<tool>deno</tool>
|
||||
<action>execute</action>
|
||||
<params>{"code": "console.log('Hello from Deno!')"}</params>
|
||||
<reasoning>Running a simple script to verify the environment</reasoning>
|
||||
</tool_call>
|
||||
|
||||
// Code with network permission
|
||||
<tool_call>
|
||||
<tool>deno</tool>
|
||||
<action>execute</action>
|
||||
<params>{
|
||||
"code": "const resp = await fetch('https://api.example.com/data'); console.log(await resp.json());",
|
||||
"permissions": ["net"]
|
||||
}</params>
|
||||
<reasoning>Fetching data from API using Deno's fetch</reasoning>
|
||||
</tool_call>
|
||||
|
||||
// Execute and parse JSON result
|
||||
<tool_call>
|
||||
<tool>deno</tool>
|
||||
<action>executeWithResult</action>
|
||||
<params>{
|
||||
"code": "const result = { sum: 2 + 2, date: new Date().toISOString() }; console.log(JSON.stringify(result));"
|
||||
}</params>
|
||||
<reasoning>Computing values and returning structured data</reasoning>
|
||||
</tool_call>
|
||||
```
|
||||
|
||||
## Guardian Policy Examples
|
||||
|
||||
The Guardian's power comes from your policy. Here are battle-tested 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;
|
||||
|
||||
// Use existing SmartAi instance (optional - avoids duplicate providers)
|
||||
smartAiInstance?: SmartAi;
|
||||
|
||||
// Provider selection
|
||||
defaultProvider?: TProvider; // For both Driver and Guardian
|
||||
guardianProvider?: TProvider; // Optional: separate provider for Guardian
|
||||
|
||||
// Agent configuration
|
||||
driverSystemMessage?: string; // Custom system message for Driver
|
||||
guardianPolicyPrompt: string; // REQUIRED: Policy for Guardian to enforce
|
||||
|
||||
// Limits
|
||||
maxIterations?: number; // Max task iterations (default: 20)
|
||||
maxConsecutiveRejections?: number; // Abort after N rejections (default: 3)
|
||||
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;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Result Interface
|
||||
## Defining Tools 🛠️
|
||||
|
||||
Tools use Vercel AI SDK's `tool()` helper with Zod schemas:
|
||||
|
||||
```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.
|
||||
}
|
||||
import { tool, z } from '@push.rocks/smartagent';
|
||||
|
||||
type TDualAgentRunStatus =
|
||||
| 'completed'
|
||||
| 'in_progress'
|
||||
| 'max_iterations_reached'
|
||||
| 'max_rejections_reached'
|
||||
| 'clarification_needed'
|
||||
| 'error';
|
||||
```
|
||||
|
||||
## Custom Tools
|
||||
|
||||
Create custom tools by extending `BaseToolWrapper`:
|
||||
|
||||
```typescript
|
||||
import { BaseToolWrapper, IToolAction, IToolExecutionResult } from '@push.rocks/smartagent';
|
||||
|
||||
class MyCustomTool extends BaseToolWrapper {
|
||||
public name = 'custom';
|
||||
public description = 'My custom tool for specific operations';
|
||||
|
||||
public actions: IToolAction[] = [
|
||||
{
|
||||
name: 'myAction',
|
||||
description: 'Performs a custom action',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string', description: 'Input for the action' },
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Setup your tool (called when orchestrator.start() runs)
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
// Cleanup resources (called when orchestrator.stop() runs)
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(action: string, params: Record<string, unknown>): Promise<IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
if (action === 'myAction') {
|
||||
return {
|
||||
success: true,
|
||||
result: { processed: params.input },
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Unknown action' };
|
||||
}
|
||||
|
||||
// Human-readable summary for Guardian evaluation
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
return `Custom action "${action}" with input "${params.input}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// Register custom tool
|
||||
orchestrator.registerTool(new MyCustomTool());
|
||||
```
|
||||
|
||||
## Reusing SmartAi Instances
|
||||
|
||||
If you already have a `@push.rocks/smartai` instance, you can share it:
|
||||
|
||||
```typescript
|
||||
import { SmartAi } from '@push.rocks/smartai';
|
||||
import { DualAgentOrchestrator } from '@push.rocks/smartagent';
|
||||
|
||||
const smartai = new SmartAi({ openaiToken: 'sk-...' });
|
||||
await smartai.start();
|
||||
|
||||
const orchestrator = new DualAgentOrchestrator({
|
||||
smartAiInstance: smartai, // Reuse existing instance
|
||||
guardianPolicyPrompt: '...',
|
||||
});
|
||||
|
||||
await orchestrator.start();
|
||||
// ... use orchestrator ...
|
||||
await orchestrator.stop();
|
||||
|
||||
// SmartAi instance lifecycle is managed separately
|
||||
await smartai.stop();
|
||||
```
|
||||
|
||||
## Supported Providers
|
||||
|
||||
SmartAgent supports all providers from `@push.rocks/smartai`:
|
||||
|
||||
| Provider | Driver | Guardian |
|
||||
|----------|:------:|:--------:|
|
||||
| OpenAI | ✅ | ✅ |
|
||||
| Anthropic | ✅ | ✅ |
|
||||
| Perplexity | ✅ | ✅ |
|
||||
| Groq | ✅ | ✅ |
|
||||
| Ollama | ✅ | ✅ |
|
||||
| XAI | ✅ | ✅ |
|
||||
| Exo | ✅ | ✅ |
|
||||
|
||||
**💡 Pro tip**: Use a faster/cheaper model for Guardian (like Groq) and a more capable model for Driver:
|
||||
|
||||
```typescript
|
||||
const orchestrator = new DualAgentOrchestrator({
|
||||
openaiToken: 'sk-...',
|
||||
groqToken: 'gsk-...',
|
||||
defaultProvider: 'openai', // Driver uses OpenAI
|
||||
guardianProvider: 'groq', // Guardian uses Groq (faster, cheaper)
|
||||
guardianPolicyPrompt: '...',
|
||||
const 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}`;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### DualAgentOrchestrator
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `start()` | Initialize all tools and AI providers |
|
||||
| `stop()` | Cleanup all tools and resources |
|
||||
| `run(task: string)` | Execute a task and return result |
|
||||
| `continueTask(input: string)` | Continue a task with user input |
|
||||
| `registerTool(tool)` | Register a custom tool |
|
||||
| `registerStandardTools()` | Register all built-in tools |
|
||||
| `registerScopedFilesystemTool(basePath)` | Register filesystem tool with path restriction |
|
||||
| `setGuardianPolicy(policy)` | Update Guardian policy at runtime |
|
||||
| `getHistory()` | Get conversation history |
|
||||
| `getToolNames()` | Get list of registered tool names |
|
||||
| `isActive()` | Check if orchestrator is running |
|
||||
|
||||
### Exports
|
||||
Pass tools as a flat object to `runAgent()`:
|
||||
|
||||
```typescript
|
||||
// Main classes
|
||||
export { DualAgentOrchestrator } from '@push.rocks/smartagent';
|
||||
export { DriverAgent } from '@push.rocks/smartagent';
|
||||
export { GuardianAgent } from '@push.rocks/smartagent';
|
||||
|
||||
// Tools
|
||||
export { BaseToolWrapper } from '@push.rocks/smartagent';
|
||||
export { FilesystemTool } from '@push.rocks/smartagent';
|
||||
export { HttpTool } from '@push.rocks/smartagent';
|
||||
export { ShellTool } from '@push.rocks/smartagent';
|
||||
export { BrowserTool } from '@push.rocks/smartagent';
|
||||
export { DenoTool } from '@push.rocks/smartagent';
|
||||
|
||||
// Types and interfaces
|
||||
export * from '@push.rocks/smartagent'; // All interfaces
|
||||
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,
|
||||
});
|
||||
|
||||
// Second turn — continues the conversation
|
||||
const turn2 = await runAgent({
|
||||
model,
|
||||
prompt: 'Now add a README to the project',
|
||||
tools,
|
||||
maxSteps: 10,
|
||||
messages: turn1.messages, // pass history
|
||||
});
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
### Main (`@push.rocks/smartagent`)
|
||||
|
||||
| Export | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `runAgent` | function | Core agentic loop |
|
||||
| `ToolRegistry` | class | Tool collection helper |
|
||||
| `truncateOutput` | function | Output truncation utility |
|
||||
| `ContextOverflowError` | class | Error type for context overflow |
|
||||
| `tool` | function | Re-exported from `@push.rocks/smartai` |
|
||||
| `z` | object | Re-exported Zod for schema definitions |
|
||||
| `stepCountIs` | function | Re-exported from AI SDK |
|
||||
| `jsonSchema` | function | Re-exported from `@push.rocks/smartai` |
|
||||
|
||||
### Tools (`@push.rocks/smartagent/tools`)
|
||||
|
||||
| Export | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `filesystemTool` | factory | File operations (read, write, list, delete) |
|
||||
| `shellTool` | factory | Shell command execution |
|
||||
| `httpTool` | factory | HTTP GET/POST requests |
|
||||
| `jsonTool` | factory | JSON validation and transformation |
|
||||
|
||||
### Compaction (`@push.rocks/smartagent/compaction`)
|
||||
|
||||
| Export | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `compactMessages` | function | Summarize message history to free context |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **[`@push.rocks/smartai`](https://code.foss.global/push.rocks/smartai)** — Provider registry, `getModel()`, re-exports `tool`/`jsonSchema`
|
||||
- **[`ai`](https://www.npmjs.com/package/ai)** v6 — Vercel AI SDK (`streamText`, `stepCountIs`, `ModelMessage`)
|
||||
- **[`zod`](https://www.npmjs.com/package/zod)** — Tool input schema definitions
|
||||
- **[`@push.rocks/smartfs`](https://code.foss.global/push.rocks/smartfs)** — Filesystem tool implementation
|
||||
- **[`@push.rocks/smartshell`](https://code.foss.global/push.rocks/smartshell)** — Shell tool implementation
|
||||
- **[`@push.rocks/smartrequest`](https://code.foss.global/push.rocks/smartrequest)** — HTTP tool implementation
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
@@ -483,7 +385,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
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, type IFilesystemToolOptions } from './smartagent.tools.filesystem.js';
|
||||
export { HttpTool } from './smartagent.tools.http.js';
|
||||
export { ShellTool } from './smartagent.tools.shell.js';
|
||||
export { BrowserTool } from './smartagent.tools.browser.js';
|
||||
export { DenoTool, type TDenoPermission } from './smartagent.tools.deno.js';
|
||||
|
||||
// Export all interfaces
|
||||
export * from './smartagent.interfaces.js';
|
||||
|
||||
// Re-export useful types from smartai
|
||||
export {
|
||||
type ISmartAiOptions,
|
||||
type TProvider,
|
||||
type ChatMessage,
|
||||
type ChatOptions,
|
||||
type ChatResponse,
|
||||
} from '@push.rocks/smartai';
|
||||
// Re-export tool() and z so consumers can define tools without extra imports
|
||||
export { tool, jsonSchema } from '@push.rocks/smartai';
|
||||
export { z } from 'zod';
|
||||
export { stepCountIs } from 'ai';
|
||||
|
||||
@@ -3,24 +3,25 @@ import * as path from 'path';
|
||||
|
||||
export { path };
|
||||
|
||||
// third party
|
||||
import { minimatch } from 'minimatch';
|
||||
// ai-sdk core
|
||||
import { streamText, generateText, stepCountIs } from 'ai';
|
||||
|
||||
export { minimatch };
|
||||
export { streamText, generateText, stepCountIs };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartai from '@push.rocks/smartai';
|
||||
import * as smartdeno from '@push.rocks/smartdeno';
|
||||
import * as smartfs from '@push.rocks/smartfs';
|
||||
import * as smartrequest from '@push.rocks/smartrequest';
|
||||
import * as smartbrowser from '@push.rocks/smartbrowser';
|
||||
import * as smartshell from '@push.rocks/smartshell';
|
||||
export type {
|
||||
ModelMessage,
|
||||
ToolSet,
|
||||
StreamTextResult,
|
||||
} from 'ai';
|
||||
|
||||
export {
|
||||
smartai,
|
||||
smartdeno,
|
||||
smartfs,
|
||||
smartrequest,
|
||||
smartbrowser,
|
||||
smartshell,
|
||||
};
|
||||
// @push.rocks/smartai
|
||||
import { tool, jsonSchema } from '@push.rocks/smartai';
|
||||
|
||||
export { tool, jsonSchema };
|
||||
|
||||
export type { LanguageModelV3 } from '@push.rocks/smartai';
|
||||
|
||||
// zod
|
||||
import { z } from 'zod';
|
||||
|
||||
export { z };
|
||||
|
||||
198
ts/smartagent.classes.agent.ts
Normal file
198
ts/smartagent.classes.agent.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Retry backoff and context overflow logic derived from opencode (MIT) — https://github.com/sst/opencode
|
||||
|
||||
import * as plugins from './plugins.js';
|
||||
import type { IAgentRunOptions, IAgentRunResult } from './smartagent.interfaces.js';
|
||||
import { ContextOverflowError } from './smartagent.interfaces.js';
|
||||
|
||||
// Retry constants
|
||||
const RETRY_INITIAL_DELAY = 2000;
|
||||
const RETRY_BACKOFF_FACTOR = 2;
|
||||
const RETRY_MAX_DELAY = 30_000;
|
||||
const MAX_RETRY_ATTEMPTS = 8;
|
||||
|
||||
function retryDelay(attempt: number, headers?: Record<string, string>): number {
|
||||
if (headers) {
|
||||
const ms = headers['retry-after-ms'];
|
||||
if (ms) {
|
||||
const n = parseFloat(ms);
|
||||
if (!isNaN(n)) return n;
|
||||
}
|
||||
const after = headers['retry-after'];
|
||||
if (after) {
|
||||
const secs = parseFloat(after);
|
||||
if (!isNaN(secs)) return Math.ceil(secs * 1000);
|
||||
const date = Date.parse(after) - Date.now();
|
||||
if (!isNaN(date) && date > 0) return Math.ceil(date);
|
||||
}
|
||||
}
|
||||
return Math.min(
|
||||
RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1),
|
||||
RETRY_MAX_DELAY,
|
||||
);
|
||||
}
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isRetryableError(err: unknown): boolean {
|
||||
const status = (err as any)?.status ?? (err as any)?.statusCode;
|
||||
if (status === 429 || status === 529 || status === 503) return true;
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes('rate limit') || msg.includes('overloaded') || msg.includes('too many requests')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isContextOverflow(err: unknown): boolean {
|
||||
if (err instanceof Error) {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes('context_length_exceeded') ||
|
||||
msg.includes('context window') ||
|
||||
msg.includes('maximum context length') ||
|
||||
msg.includes('too many tokens') ||
|
||||
msg.includes('input is too long') ||
|
||||
(err as any)?.name === 'AI_ContextWindowExceededError'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResult> {
|
||||
let stepCount = 0;
|
||||
let attempt = 0;
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
|
||||
const tools = options.tools ?? {};
|
||||
|
||||
// Add a no-op sink for repaired-but-unrecognised tool calls
|
||||
const allTools: plugins.ToolSet = {
|
||||
...tools,
|
||||
invalid: plugins.tool({
|
||||
description: 'Sink for unrecognised tool calls — returns an error message to the model',
|
||||
inputSchema: plugins.z.object({
|
||||
tool: plugins.z.string(),
|
||||
error: plugins.z.string(),
|
||||
}),
|
||||
execute: async ({ tool, error }: { tool: string; error: string }) =>
|
||||
`Unknown tool "${tool}": ${error}`,
|
||||
}),
|
||||
};
|
||||
|
||||
// Build messages — streamText requires either prompt OR messages, not both
|
||||
let messages: plugins.ModelMessage[] = options.messages
|
||||
? [...options.messages, { role: 'user' as const, content: options.prompt }]
|
||||
: [{ role: 'user' as const, content: options.prompt }];
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = plugins.streamText({
|
||||
model: options.model,
|
||||
system: options.system,
|
||||
messages,
|
||||
tools: allTools,
|
||||
stopWhen: plugins.stepCountIs(options.maxSteps ?? 20),
|
||||
maxRetries: 0, // handled manually below
|
||||
abortSignal: options.abort,
|
||||
|
||||
experimental_repairToolCall: async ({ toolCall, tools: availableTools, error }) => {
|
||||
const lower = toolCall.toolName.toLowerCase();
|
||||
if (lower !== toolCall.toolName && (availableTools as any)[lower]) {
|
||||
return { ...toolCall, toolName: lower };
|
||||
}
|
||||
return {
|
||||
...toolCall,
|
||||
toolName: 'invalid',
|
||||
args: JSON.stringify({
|
||||
tool: toolCall.toolName,
|
||||
error: String(error),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
onChunk: ({ chunk }) => {
|
||||
if (chunk.type === 'text-delta' && options.onToken) {
|
||||
options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? '');
|
||||
}
|
||||
},
|
||||
|
||||
experimental_onToolCallStart: options.onToolCall
|
||||
? ({ toolCall }) => {
|
||||
options.onToolCall!(toolCall.toolName, (toolCall as any).input ?? (toolCall as any).args);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
experimental_onToolCallFinish: options.onToolResult
|
||||
? ({ toolCall, 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,399 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import type { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Options for configuring the DriverAgent
|
||||
*/
|
||||
export interface IDriverAgentOptions {
|
||||
/** Custom system message for the driver */
|
||||
systemMessage?: string;
|
||||
/** Maximum history messages to pass to API (default: 20). Set to 0 for unlimited. */
|
||||
maxHistoryMessages?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DriverAgent - Executes tasks by reasoning and proposing tool calls
|
||||
* Works in conjunction with GuardianAgent for approval
|
||||
*/
|
||||
export class DriverAgent {
|
||||
private provider: plugins.smartai.MultiModalModel;
|
||||
private systemMessage: string;
|
||||
private maxHistoryMessages: number;
|
||||
private messageHistory: plugins.smartai.ChatMessage[] = [];
|
||||
private tools: Map<string, BaseToolWrapper> = new Map();
|
||||
|
||||
constructor(
|
||||
provider: plugins.smartai.MultiModalModel,
|
||||
options?: IDriverAgentOptions | string
|
||||
) {
|
||||
this.provider = provider;
|
||||
|
||||
// Support both legacy string systemMessage and new options object
|
||||
if (typeof options === 'string') {
|
||||
this.systemMessage = options || this.getDefaultSystemMessage();
|
||||
this.maxHistoryMessages = 20;
|
||||
} else {
|
||||
this.systemMessage = options?.systemMessage || this.getDefaultSystemMessage();
|
||||
this.maxHistoryMessages = options?.maxHistoryMessages ?? 20;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 based on available tools
|
||||
const hasTools = this.tools.size > 0;
|
||||
let userMessage: string;
|
||||
if (hasTools) {
|
||||
userMessage = `TASK: ${task}\n\nAnalyze this task and determine what actions are needed. If you need to use a tool, provide a tool call proposal.`;
|
||||
} else {
|
||||
userMessage = `TASK: ${task}\n\nComplete this task directly. When done, wrap your final output in <task_complete>your output here</task_complete> tags.`;
|
||||
}
|
||||
|
||||
// Add to history
|
||||
this.messageHistory.push({
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
});
|
||||
|
||||
// Build the system message - adapt based on available tools
|
||||
let fullSystemMessage: string;
|
||||
if (hasTools) {
|
||||
const toolDescriptions = this.buildToolDescriptions();
|
||||
fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
|
||||
} else {
|
||||
// Use a simpler system message when no tools are available
|
||||
fullSystemMessage = this.getNoToolsSystemMessage();
|
||||
}
|
||||
|
||||
// Get response from provider
|
||||
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 the system message - adapt based on available tools
|
||||
const hasTools = this.tools.size > 0;
|
||||
let fullSystemMessage: string;
|
||||
if (hasTools) {
|
||||
const toolDescriptions = this.buildToolDescriptions();
|
||||
fullSystemMessage = `${this.systemMessage}\n\n## Available Tools\n${toolDescriptions}`;
|
||||
} else {
|
||||
fullSystemMessage = this.getNoToolsSystemMessage();
|
||||
}
|
||||
|
||||
// Get response from provider with history windowing
|
||||
// Keep original task and most recent messages to avoid token explosion
|
||||
let historyForChat: plugins.smartai.ChatMessage[];
|
||||
const fullHistory = this.messageHistory.slice(0, -1); // Exclude the just-added message
|
||||
|
||||
if (this.maxHistoryMessages > 0 && fullHistory.length > this.maxHistoryMessages) {
|
||||
// Keep the original task (first message) and most recent messages
|
||||
historyForChat = [
|
||||
fullHistory[0], // Original task
|
||||
...fullHistory.slice(-(this.maxHistoryMessages - 1)), // Recent messages
|
||||
];
|
||||
} else {
|
||||
historyForChat = fullHistory;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system message when no tools are available
|
||||
* Used for direct task completion without tool usage
|
||||
*/
|
||||
private getNoToolsSystemMessage(): string {
|
||||
// Use custom system message if provided, otherwise use a simple default
|
||||
if (this.systemMessage && this.systemMessage !== this.getDefaultSystemMessage()) {
|
||||
return this.systemMessage;
|
||||
}
|
||||
|
||||
return `You are an AI assistant that completes tasks directly.
|
||||
|
||||
## Your Role
|
||||
You analyze tasks and provide complete, high-quality outputs.
|
||||
|
||||
## Output Format
|
||||
When you have completed the task, wrap your final output in task_complete tags:
|
||||
|
||||
<task_complete>
|
||||
Your complete output here
|
||||
</task_complete>
|
||||
|
||||
## Guidelines
|
||||
1. Analyze the task requirements carefully
|
||||
2. Provide a complete and accurate response
|
||||
3. Always wrap your final output in <task_complete></task_complete> tags
|
||||
4. If you need clarification, ask using <needs_clarification>your question</needs_clarification>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the conversation state
|
||||
*/
|
||||
public reset(): void {
|
||||
this.messageHistory = [];
|
||||
}
|
||||
}
|
||||
@@ -1,559 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
import { DriverAgent } from './smartagent.classes.driveragent.js';
|
||||
import { GuardianAgent } from './smartagent.classes.guardianagent.js';
|
||||
import { FilesystemTool } from './smartagent.tools.filesystem.js';
|
||||
import { HttpTool } from './smartagent.tools.http.js';
|
||||
import { ShellTool } from './smartagent.tools.shell.js';
|
||||
import { BrowserTool } from './smartagent.tools.browser.js';
|
||||
import { DenoTool } from './smartagent.tools.deno.js';
|
||||
|
||||
/**
|
||||
* DualAgentOrchestrator - Coordinates Driver and Guardian agents
|
||||
* Manages the complete lifecycle of task execution with tool approval
|
||||
*/
|
||||
export class DualAgentOrchestrator {
|
||||
private options: interfaces.IDualAgentOptions;
|
||||
private smartai: plugins.smartai.SmartAi;
|
||||
private driverProvider: plugins.smartai.MultiModalModel;
|
||||
private guardianProvider: plugins.smartai.MultiModalModel;
|
||||
private driver: DriverAgent;
|
||||
private guardian: GuardianAgent;
|
||||
private tools: Map<string, BaseToolWrapper> = new Map();
|
||||
private isRunning = false;
|
||||
private conversationHistory: interfaces.IAgentMessage[] = [];
|
||||
private ownsSmartAi = true; // true if we created the SmartAi instance, false if it was provided
|
||||
|
||||
constructor(options: interfaces.IDualAgentOptions) {
|
||||
this.options = {
|
||||
maxIterations: 20,
|
||||
maxConsecutiveRejections: 3,
|
||||
defaultProvider: 'openai',
|
||||
maxResultChars: 15000,
|
||||
maxHistoryMessages: 20,
|
||||
...options,
|
||||
};
|
||||
|
||||
// Use existing SmartAi instance if provided, otherwise create a new one
|
||||
if (options.smartAiInstance) {
|
||||
this.smartai = options.smartAiInstance;
|
||||
this.ownsSmartAi = false; // Don't manage lifecycle of provided instance
|
||||
} else {
|
||||
this.smartai = new plugins.smartai.SmartAi(options);
|
||||
this.ownsSmartAi = true;
|
||||
}
|
||||
// Note: Don't access providers here - they don't exist until start() is called
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by name
|
||||
*/
|
||||
private getProviderByName(providerName: plugins.smartai.TProvider): plugins.smartai.MultiModalModel {
|
||||
switch (providerName) {
|
||||
case 'openai':
|
||||
return this.smartai.openaiProvider;
|
||||
case 'anthropic':
|
||||
return this.smartai.anthropicProvider;
|
||||
case 'perplexity':
|
||||
return this.smartai.perplexityProvider;
|
||||
case 'ollama':
|
||||
return this.smartai.ollamaProvider;
|
||||
case 'groq':
|
||||
return this.smartai.groqProvider;
|
||||
case 'xai':
|
||||
return this.smartai.xaiProvider;
|
||||
case 'exo':
|
||||
return this.smartai.exoProvider;
|
||||
default:
|
||||
return this.smartai.openaiProvider;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a progress event if callback is configured
|
||||
*/
|
||||
private emitProgress(event: Omit<interfaces.IProgressEvent, 'timestamp' | 'logLevel' | 'logMessage'>): void {
|
||||
if (this.options.onProgress) {
|
||||
const prefix = this.options.logPrefix ? `${this.options.logPrefix} ` : '';
|
||||
const { logLevel, logMessage } = this.formatProgressEvent(event, prefix);
|
||||
|
||||
this.options.onProgress({
|
||||
...event,
|
||||
timestamp: new Date(),
|
||||
logLevel,
|
||||
logMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a progress event into a log level and message
|
||||
*/
|
||||
private formatProgressEvent(
|
||||
event: Omit<interfaces.IProgressEvent, 'timestamp' | 'logLevel' | 'logMessage'>,
|
||||
prefix: string
|
||||
): { logLevel: interfaces.TLogLevel; logMessage: string } {
|
||||
switch (event.type) {
|
||||
case 'task_started':
|
||||
return { logLevel: 'info', logMessage: `${prefix}Task started` };
|
||||
case 'iteration_started':
|
||||
return { logLevel: 'info', logMessage: `${prefix}Iteration ${event.iteration}/${event.maxIterations}` };
|
||||
case 'tool_proposed':
|
||||
return { logLevel: 'info', logMessage: `${prefix} → Proposing: ${event.toolName}.${event.action}` };
|
||||
case 'guardian_evaluating':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ⏳ Guardian evaluating...` };
|
||||
case 'tool_approved':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ✓ Approved: ${event.toolName}.${event.action}` };
|
||||
case 'tool_rejected':
|
||||
return { logLevel: 'warn', logMessage: `${prefix} ✗ Rejected: ${event.toolName}.${event.action} - ${event.reason}` };
|
||||
case 'tool_executing':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ⚡ Executing: ${event.toolName}.${event.action}...` };
|
||||
case 'tool_completed':
|
||||
return { logLevel: 'info', logMessage: `${prefix} ✓ Completed: ${event.message}` };
|
||||
case 'task_completed':
|
||||
return { logLevel: 'success', logMessage: `${prefix}Task completed in ${event.iteration} iterations` };
|
||||
case 'clarification_needed':
|
||||
return { logLevel: 'warn', logMessage: `${prefix}Clarification needed from user` };
|
||||
case 'max_iterations':
|
||||
return { logLevel: 'error', logMessage: `${prefix}${event.message}` };
|
||||
case 'max_rejections':
|
||||
return { logLevel: 'error', logMessage: `${prefix}${event.message}` };
|
||||
default:
|
||||
return { logLevel: 'info', logMessage: `${prefix}${event.type}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom tool
|
||||
*/
|
||||
public registerTool(tool: BaseToolWrapper): void {
|
||||
this.tools.set(tool.name, tool);
|
||||
// Register with agents if they exist (they're created in start())
|
||||
if (this.driver) {
|
||||
this.driver.registerTool(tool);
|
||||
}
|
||||
if (this.guardian) {
|
||||
this.guardian.registerTool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all standard tools
|
||||
*/
|
||||
public registerStandardTools(): void {
|
||||
const standardTools = [
|
||||
new FilesystemTool(),
|
||||
new HttpTool(),
|
||||
new ShellTool(),
|
||||
new BrowserTool(),
|
||||
new DenoTool(),
|
||||
];
|
||||
|
||||
for (const tool of standardTools) {
|
||||
this.registerTool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a scoped filesystem tool that can only access files within the specified directory
|
||||
* @param basePath The directory to scope filesystem operations to
|
||||
* @param excludePatterns Optional glob patterns to exclude from listings (e.g., ['.nogit/**', 'node_modules/**'])
|
||||
*/
|
||||
public registerScopedFilesystemTool(basePath: string, excludePatterns?: string[]): void {
|
||||
const scopedTool = new FilesystemTool({ basePath, excludePatterns });
|
||||
this.registerTool(scopedTool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all tools (eager loading)
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Start smartai only if we created it (external instances should already be started)
|
||||
if (this.ownsSmartAi) {
|
||||
await this.smartai.start();
|
||||
}
|
||||
|
||||
// NOW get providers (after they've been initialized by smartai.start())
|
||||
this.driverProvider = this.getProviderByName(this.options.defaultProvider!);
|
||||
this.guardianProvider = this.options.guardianProvider
|
||||
? this.getProviderByName(this.options.guardianProvider)
|
||||
: this.driverProvider;
|
||||
|
||||
// NOW create agents with initialized providers
|
||||
this.driver = new DriverAgent(this.driverProvider, {
|
||||
systemMessage: this.options.driverSystemMessage,
|
||||
maxHistoryMessages: this.options.maxHistoryMessages,
|
||||
});
|
||||
this.guardian = new GuardianAgent(this.guardianProvider, this.options.guardianPolicyPrompt);
|
||||
|
||||
// Register any tools that were added before start() with the agents
|
||||
for (const tool of this.tools.values()) {
|
||||
this.driver.registerTool(tool);
|
||||
this.guardian.registerTool(tool);
|
||||
}
|
||||
|
||||
// Initialize all tools
|
||||
const initPromises: Promise<void>[] = [];
|
||||
for (const tool of this.tools.values()) {
|
||||
initPromises.push(tool.initialize());
|
||||
}
|
||||
|
||||
await Promise.all(initPromises);
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all tools
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
const cleanupPromises: Promise<void>[] = [];
|
||||
|
||||
for (const tool of this.tools.values()) {
|
||||
cleanupPromises.push(tool.cleanup());
|
||||
}
|
||||
|
||||
await Promise.all(cleanupPromises);
|
||||
|
||||
// Only stop smartai if we created it (don't stop external instances)
|
||||
if (this.ownsSmartAi) {
|
||||
await this.smartai.stop();
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
if (this.driver) {
|
||||
this.driver.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a task through the dual-agent system
|
||||
*/
|
||||
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);
|
||||
|
||||
// Emit task started event
|
||||
this.emitProgress({
|
||||
type: 'task_started',
|
||||
message: task.length > 100 ? task.substring(0, 100) + '...' : task,
|
||||
});
|
||||
|
||||
while (
|
||||
iterations < this.options.maxIterations! &&
|
||||
consecutiveRejections < this.options.maxConsecutiveRejections! &&
|
||||
!completed
|
||||
) {
|
||||
iterations++;
|
||||
|
||||
// Emit iteration started event
|
||||
this.emitProgress({
|
||||
type: 'iteration_started',
|
||||
iteration: iterations,
|
||||
maxIterations: this.options.maxIterations,
|
||||
});
|
||||
|
||||
// Check if task is complete
|
||||
if (this.driver.isTaskComplete(driverResponse.content)) {
|
||||
completed = true;
|
||||
finalResult = this.driver.extractTaskResult(driverResponse.content) || driverResponse.content;
|
||||
|
||||
// Emit task completed event
|
||||
this.emitProgress({
|
||||
type: 'task_completed',
|
||||
iteration: iterations,
|
||||
message: 'Task completed successfully',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if driver needs clarification
|
||||
if (this.driver.needsClarification(driverResponse.content)) {
|
||||
// Emit clarification needed event
|
||||
this.emitProgress({
|
||||
type: 'clarification_needed',
|
||||
iteration: iterations,
|
||||
message: 'Driver needs clarification from user',
|
||||
});
|
||||
|
||||
// Return with clarification needed status
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
result: driverResponse.content,
|
||||
iterations,
|
||||
history: this.conversationHistory,
|
||||
status: 'clarification_needed',
|
||||
};
|
||||
}
|
||||
|
||||
// Parse tool call proposals
|
||||
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];
|
||||
|
||||
// Emit tool proposed event
|
||||
this.emitProgress({
|
||||
type: 'tool_proposed',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
message: `${proposal.toolName}.${proposal.action}`,
|
||||
});
|
||||
|
||||
// Quick validation first
|
||||
const quickDecision = this.guardian.quickValidate(proposal);
|
||||
let decision: interfaces.IGuardianDecision;
|
||||
|
||||
if (quickDecision) {
|
||||
decision = quickDecision;
|
||||
} else {
|
||||
// Emit guardian evaluating event
|
||||
this.emitProgress({
|
||||
type: 'guardian_evaluating',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
});
|
||||
|
||||
// Full AI evaluation
|
||||
decision = await this.guardian.evaluate(proposal, task);
|
||||
}
|
||||
|
||||
if (decision.decision === 'approve') {
|
||||
consecutiveRejections = 0;
|
||||
|
||||
// Emit tool approved event
|
||||
this.emitProgress({
|
||||
type: 'tool_approved',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
});
|
||||
|
||||
// Execute the tool
|
||||
const tool = this.tools.get(proposal.toolName);
|
||||
if (!tool) {
|
||||
const errorMessage = `Tool "${proposal.toolName}" not found.`;
|
||||
driverResponse = await this.driver.continueWithMessage(
|
||||
`TOOL ERROR: ${errorMessage}\n\nPlease try a different approach.`
|
||||
);
|
||||
this.conversationHistory.push(driverResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Emit tool executing event
|
||||
this.emitProgress({
|
||||
type: 'tool_executing',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
});
|
||||
|
||||
const result = await tool.execute(proposal.action, proposal.params);
|
||||
|
||||
// Emit tool completed event
|
||||
this.emitProgress({
|
||||
type: 'tool_completed',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
message: result.success ? 'success' : result.error,
|
||||
});
|
||||
|
||||
// Build result message (prefer summary if provided, otherwise stringify result)
|
||||
let resultMessage: string;
|
||||
if (result.success) {
|
||||
if (result.summary) {
|
||||
// Use tool-provided summary
|
||||
resultMessage = `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${result.summary}`;
|
||||
} else {
|
||||
// Stringify and potentially truncate
|
||||
const resultStr = JSON.stringify(result.result, null, 2);
|
||||
const maxChars = this.options.maxResultChars ?? 15000;
|
||||
|
||||
if (maxChars > 0 && resultStr.length > maxChars) {
|
||||
// Truncate the result
|
||||
const truncated = resultStr.substring(0, maxChars);
|
||||
const omittedTokens = Math.round((resultStr.length - maxChars) / 4);
|
||||
resultMessage = `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${truncated}\n\n[... output truncated, ~${omittedTokens} tokens omitted. Use more specific parameters to reduce output size.]`;
|
||||
} else {
|
||||
resultMessage = `TOOL RESULT (${proposal.toolName}.${proposal.action}):\n${resultStr}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resultMessage = `TOOL ERROR (${proposal.toolName}.${proposal.action}):\n${result.error}`;
|
||||
}
|
||||
|
||||
this.conversationHistory.push({
|
||||
role: 'system',
|
||||
content: resultMessage,
|
||||
toolCall: proposal,
|
||||
toolResult: result,
|
||||
});
|
||||
|
||||
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++;
|
||||
|
||||
// Emit tool rejected event
|
||||
this.emitProgress({
|
||||
type: 'tool_rejected',
|
||||
iteration: iterations,
|
||||
toolName: proposal.toolName,
|
||||
action: proposal.action,
|
||||
reason: decision.reason,
|
||||
});
|
||||
|
||||
// Build rejection feedback
|
||||
let feedback = `TOOL CALL REJECTED by Guardian:\n`;
|
||||
feedback += `- Reason: ${decision.reason}\n`;
|
||||
|
||||
if (decision.concerns && decision.concerns.length > 0) {
|
||||
feedback += `- Concerns:\n${decision.concerns.map(c => ` - ${c}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
if (decision.suggestions) {
|
||||
feedback += `- Suggestions: ${decision.suggestions}\n`;
|
||||
}
|
||||
|
||||
feedback += `\nPlease adapt your approach based on this feedback.`;
|
||||
|
||||
this.conversationHistory.push({
|
||||
role: 'system',
|
||||
content: feedback,
|
||||
toolCall: proposal,
|
||||
guardianDecision: decision,
|
||||
});
|
||||
|
||||
driverResponse = await this.driver.continueWithMessage(feedback);
|
||||
this.conversationHistory.push(driverResponse);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final status
|
||||
let status: interfaces.TDualAgentRunStatus = 'completed';
|
||||
if (!completed) {
|
||||
if (iterations >= this.options.maxIterations!) {
|
||||
status = 'max_iterations_reached';
|
||||
// Emit max iterations event
|
||||
this.emitProgress({
|
||||
type: 'max_iterations',
|
||||
iteration: iterations,
|
||||
maxIterations: this.options.maxIterations,
|
||||
message: `Maximum iterations (${this.options.maxIterations}) reached`,
|
||||
});
|
||||
} else if (consecutiveRejections >= this.options.maxConsecutiveRejections!) {
|
||||
status = 'max_rejections_reached';
|
||||
// Emit max rejections event
|
||||
this.emitProgress({
|
||||
type: 'max_rejections',
|
||||
iteration: iterations,
|
||||
message: `Maximum consecutive rejections (${this.options.maxConsecutiveRejections}) reached`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: completed,
|
||||
completed,
|
||||
result: finalResult || driverResponse.content,
|
||||
iterations,
|
||||
history: this.conversationHistory,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue an existing task with user input
|
||||
*/
|
||||
public async continueTask(userInput: string): Promise<interfaces.IDualAgentRunResult> {
|
||||
if (!this.isRunning) {
|
||||
throw new Error('Orchestrator not started. Call start() first.');
|
||||
}
|
||||
|
||||
this.conversationHistory.push({
|
||||
role: 'user',
|
||||
content: userInput,
|
||||
});
|
||||
|
||||
const driverResponse = await this.driver.continueWithMessage(userInput);
|
||||
this.conversationHistory.push(driverResponse);
|
||||
|
||||
// Continue the run loop
|
||||
// For simplicity, we return the current state - full continuation would need refactoring
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
result: driverResponse.content,
|
||||
iterations: 1,
|
||||
history: this.conversationHistory,
|
||||
status: 'in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation history
|
||||
*/
|
||||
public getHistory(): interfaces.IAgentMessage[] {
|
||||
return [...this.conversationHistory];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the guardian policy
|
||||
*/
|
||||
public setGuardianPolicy(policyPrompt: string): void {
|
||||
this.guardian.setPolicy(policyPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if orchestrator is running
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered tool names
|
||||
*/
|
||||
public getToolNames(): string[] {
|
||||
return Array.from(this.tools.keys());
|
||||
}
|
||||
}
|
||||
@@ -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,274 +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 {
|
||||
/** Existing SmartAi instance to reuse (avoids creating duplicate providers) */
|
||||
smartAiInstance?: plugins.smartai.SmartAi;
|
||||
/** Name of the agent system */
|
||||
name?: string;
|
||||
/** Default AI provider for both Driver and Guardian */
|
||||
defaultProvider?: plugins.smartai.TProvider;
|
||||
/** Optional separate provider for Guardian (for cost optimization) */
|
||||
guardianProvider?: plugins.smartai.TProvider;
|
||||
/** System message for the Driver agent */
|
||||
driverSystemMessage?: string;
|
||||
/** Policy prompt for the Guardian agent - REQUIRED */
|
||||
guardianPolicyPrompt: string;
|
||||
/** Maximum iterations for task completion (default: 20) */
|
||||
maxIterations?: number;
|
||||
/** Maximum consecutive rejections before aborting (default: 3) */
|
||||
maxConsecutiveRejections?: number;
|
||||
/** Enable verbose logging */
|
||||
verbose?: boolean;
|
||||
/** Maximum characters for tool result output before truncation (default: 15000). Set to 0 to disable. */
|
||||
maxResultChars?: number;
|
||||
/** Maximum history messages to pass to API (default: 20). Set to 0 for unlimited. */
|
||||
maxHistoryMessages?: number;
|
||||
/** Optional callback for live progress updates during execution */
|
||||
onProgress?: (event: IProgressEvent) => void;
|
||||
/** Prefix for log messages (e.g., "[README]", "[Commit]"). Default: empty */
|
||||
logPrefix?: string;
|
||||
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;
|
||||
/** Optional human-readable summary for history (if provided, used instead of full result) */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base interface for wrapped tools
|
||||
*/
|
||||
export interface IAgentToolWrapper {
|
||||
/** Tool name */
|
||||
name: string;
|
||||
/** Tool description */
|
||||
description: string;
|
||||
/** Available actions */
|
||||
actions: IToolAction[];
|
||||
/** Initialize the tool */
|
||||
initialize(): Promise<void>;
|
||||
/** Cleanup resources */
|
||||
cleanup(): Promise<void>;
|
||||
/** Execute an action */
|
||||
execute(action: string, params: Record<string, unknown>): Promise<IToolExecutionResult>;
|
||||
/** Get a summary for Guardian review */
|
||||
getCallSummary(action: string, params: Record<string, unknown>): string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Guardian Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Request for Guardian evaluation
|
||||
*/
|
||||
export interface IGuardianEvaluationRequest {
|
||||
/** The proposed tool call */
|
||||
proposal: IToolCallProposal;
|
||||
/** Current task context */
|
||||
taskContext: string;
|
||||
/** Recent conversation history (last N messages) */
|
||||
recentHistory: IAgentMessage[];
|
||||
/** Summary of what the tool call will do */
|
||||
callSummary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guardian's decision
|
||||
*/
|
||||
export interface IGuardianDecision {
|
||||
/** Approve or reject */
|
||||
decision: 'approve' | 'reject';
|
||||
/** Explanation of the decision */
|
||||
reason: string;
|
||||
/** Specific concerns if rejected */
|
||||
concerns?: string[];
|
||||
/** Suggestions for the Driver if rejected */
|
||||
suggestions?: string;
|
||||
/** Confidence level (0-1) */
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Result Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Log entry for tool executions
|
||||
*/
|
||||
export interface IToolExecutionLog {
|
||||
timestamp: Date;
|
||||
toolName: string;
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
guardianDecision: 'approved' | 'rejected';
|
||||
guardianReason: string;
|
||||
executionResult?: unknown;
|
||||
executionError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of a dual-agent run
|
||||
*/
|
||||
export type TDualAgentRunStatus =
|
||||
| 'completed'
|
||||
| 'in_progress'
|
||||
| 'max_iterations_reached'
|
||||
| 'max_rejections_reached'
|
||||
| 'clarification_needed'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* Result of a dual-agent run
|
||||
*/
|
||||
export interface IDualAgentRunResult {
|
||||
/** Whether the task was successful */
|
||||
success: boolean;
|
||||
/** Whether the task is completed */
|
||||
completed: boolean;
|
||||
/** Final result or response */
|
||||
result: string;
|
||||
/** Total iterations taken */
|
||||
iterations: number;
|
||||
/** Full conversation history */
|
||||
history: IAgentMessage[];
|
||||
/** Current status */
|
||||
status: TDualAgentRunStatus;
|
||||
/** Number of tool calls made */
|
||||
toolCallCount?: number;
|
||||
/** Number of Guardian rejections */
|
||||
rejectionCount?: number;
|
||||
/** Tool execution log */
|
||||
toolLog?: IToolExecutionLog[];
|
||||
/** Error message if status is 'error' */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Progress Event Interfaces
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Progress event types for live feedback during agent execution
|
||||
*/
|
||||
export type TProgressEventType =
|
||||
| 'task_started'
|
||||
| 'iteration_started'
|
||||
| 'tool_proposed'
|
||||
| 'guardian_evaluating'
|
||||
| 'tool_approved'
|
||||
| 'tool_rejected'
|
||||
| 'tool_executing'
|
||||
| 'tool_completed'
|
||||
| 'task_completed'
|
||||
| 'clarification_needed'
|
||||
| 'max_iterations'
|
||||
| 'max_rejections';
|
||||
|
||||
/**
|
||||
* Log level for progress events
|
||||
*/
|
||||
export type TLogLevel = 'info' | 'warn' | 'error' | 'success';
|
||||
|
||||
/**
|
||||
* Progress event for live feedback during agent execution
|
||||
*/
|
||||
export interface IProgressEvent {
|
||||
/** Type of progress event */
|
||||
type: TProgressEventType;
|
||||
/** Current iteration number */
|
||||
iteration?: number;
|
||||
/** Maximum iterations configured */
|
||||
maxIterations?: number;
|
||||
/** Name of the tool being used */
|
||||
toolName?: string;
|
||||
/** Action being performed */
|
||||
action?: string;
|
||||
/** Reason for rejection or other explanation */
|
||||
reason?: string;
|
||||
/** Human-readable message about the event */
|
||||
message?: string;
|
||||
/** Timestamp of the event */
|
||||
timestamp: Date;
|
||||
/** Log level for this event (info, warn, error, success) */
|
||||
logLevel: TLogLevel;
|
||||
/** Pre-formatted log message ready for output */
|
||||
logMessage: string;
|
||||
}
|
||||
|
||||
// ================================
|
||||
// Utility Types
|
||||
// ================================
|
||||
|
||||
/**
|
||||
* Available tool names
|
||||
*/
|
||||
export type TToolName = 'filesystem' | 'http' | 'browser' | 'shell';
|
||||
|
||||
/**
|
||||
* Generate a unique proposal ID
|
||||
*/
|
||||
export function generateProposalId(): string {
|
||||
return `proposal_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
export class ContextOverflowError extends Error {
|
||||
constructor(message = 'Agent context limit reached and no onContextOverflow handler provided') {
|
||||
super(message);
|
||||
this.name = 'ContextOverflowError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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,721 +0,0 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from './smartagent.interfaces.js';
|
||||
import { BaseToolWrapper } from './smartagent.tools.base.js';
|
||||
|
||||
/**
|
||||
* Options for FilesystemTool
|
||||
*/
|
||||
export interface IFilesystemToolOptions {
|
||||
/** Base path to scope all operations to. If set, all paths must be within this directory. */
|
||||
basePath?: string;
|
||||
/** Glob patterns to exclude from listings (e.g., ['.nogit/**', 'node_modules/**']) */
|
||||
excludePatterns?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem tool for file and directory operations
|
||||
* Wraps @push.rocks/smartfs
|
||||
*/
|
||||
export class FilesystemTool extends BaseToolWrapper {
|
||||
public name = 'filesystem';
|
||||
public description = 'Read, write, list, and delete files and directories';
|
||||
|
||||
/** Base path to scope all operations to */
|
||||
private basePath?: string;
|
||||
/** Glob patterns to exclude from listings */
|
||||
private excludePatterns: string[];
|
||||
|
||||
constructor(options?: IFilesystemToolOptions) {
|
||||
super();
|
||||
if (options?.basePath) {
|
||||
this.basePath = plugins.path.resolve(options.basePath);
|
||||
}
|
||||
this.excludePatterns = options?.excludePatterns || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a relative path should be excluded based on exclude patterns
|
||||
*/
|
||||
private isExcluded(relativePath: string): boolean {
|
||||
if (this.excludePatterns.length === 0) return false;
|
||||
return this.excludePatterns.some(pattern =>
|
||||
plugins.minimatch(relativePath, pattern, { dot: true })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a path is within the allowed base path
|
||||
* @throws Error if path is outside allowed directory
|
||||
*/
|
||||
private validatePath(pathArg: string): string {
|
||||
const resolved = plugins.path.resolve(pathArg);
|
||||
if (this.basePath) {
|
||||
// Ensure the resolved path starts with the base path
|
||||
if (!resolved.startsWith(this.basePath + plugins.path.sep) && resolved !== this.basePath) {
|
||||
throw new Error(`Access denied: path "${pathArg}" is outside allowed directory "${this.basePath}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public actions: interfaces.IToolAction[] = [
|
||||
{
|
||||
name: 'read',
|
||||
description: 'Read file contents (full or specific line range)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to the file' },
|
||||
encoding: {
|
||||
type: 'string',
|
||||
enum: ['utf8', 'binary', 'base64'],
|
||||
default: 'utf8',
|
||||
description: 'File encoding',
|
||||
},
|
||||
startLine: {
|
||||
type: 'number',
|
||||
description: 'First line to read (1-indexed, inclusive). If omitted, reads from beginning.',
|
||||
},
|
||||
endLine: {
|
||||
type: 'number',
|
||||
description: 'Last line to read (1-indexed, inclusive). If omitted, reads to end.',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'write',
|
||||
description: 'Write content to a file (creates or overwrites)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Absolute path to the file' },
|
||||
content: { type: 'string', description: 'Content to write' },
|
||||
encoding: {
|
||||
type: 'string',
|
||||
enum: ['utf8', 'binary', 'base64'],
|
||||
default: 'utf8',
|
||||
description: 'File encoding',
|
||||
},
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'append',
|
||||
description: 'Append content to a file',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Absolute path to the file' },
|
||||
content: { type: 'string', description: 'Content to append' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List files and directories in a path',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to list' },
|
||||
recursive: { type: 'boolean', default: false, description: 'List recursively' },
|
||||
filter: { type: 'string', description: 'Glob pattern to filter results (e.g., "*.ts")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
description: 'Delete a file or directory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to delete' },
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'For directories, delete recursively',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'exists',
|
||||
description: 'Check if a file or directory exists',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to check' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'stat',
|
||||
description: 'Get file or directory statistics (size, dates, etc.)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Path to get stats for' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'copy',
|
||||
description: 'Copy a file to a new location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', description: 'Source file path' },
|
||||
destination: { type: 'string', description: 'Destination file path' },
|
||||
},
|
||||
required: ['source', 'destination'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'move',
|
||||
description: 'Move a file to a new location',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string', description: 'Source file path' },
|
||||
destination: { type: 'string', description: 'Destination file path' },
|
||||
},
|
||||
required: ['source', 'destination'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mkdir',
|
||||
description: 'Create a directory',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Directory path to create' },
|
||||
recursive: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description: 'Create parent directories if needed',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tree',
|
||||
description: 'Show directory structure as a tree (no file contents)',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Root directory path' },
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
default: 3,
|
||||
description: 'Maximum depth to traverse (default: 3)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern to filter files (e.g., "*.ts")',
|
||||
},
|
||||
showSizes: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include file sizes in output',
|
||||
},
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['string', 'json'],
|
||||
default: 'string',
|
||||
description: 'Output format: "string" for human-readable tree, "json" for structured array',
|
||||
},
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'glob',
|
||||
description: 'Find files matching a glob pattern',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern (e.g., "**/*.ts", "src/**/*.js")',
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Base path to search from (defaults to current directory)',
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
private smartfs!: plugins.smartfs.SmartFs;
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
this.smartfs = new plugins.smartfs.SmartFs(new plugins.smartfs.SmartFsProviderNode());
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
public async execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<interfaces.IToolExecutionResult> {
|
||||
this.validateAction(action);
|
||||
this.ensureInitialized();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'read': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const encoding = (params.encoding as string) || 'utf8';
|
||||
const startLine = params.startLine as number | undefined;
|
||||
const endLine = params.endLine as number | undefined;
|
||||
|
||||
const fullContent = await this.smartfs
|
||||
.file(validatedPath)
|
||||
.encoding(encoding as 'utf8' | 'binary' | 'base64')
|
||||
.read();
|
||||
|
||||
const contentStr = fullContent.toString();
|
||||
const lines = contentStr.split('\n');
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Apply line range if specified
|
||||
let resultContent: string;
|
||||
let resultStartLine = 1;
|
||||
let resultEndLine = totalLines;
|
||||
|
||||
if (startLine !== undefined || endLine !== undefined) {
|
||||
const start = Math.max(1, startLine ?? 1);
|
||||
const end = Math.min(totalLines, endLine ?? totalLines);
|
||||
resultStartLine = start;
|
||||
resultEndLine = end;
|
||||
|
||||
// Convert to 0-indexed for array slicing
|
||||
const selectedLines = lines.slice(start - 1, end);
|
||||
|
||||
// Add line numbers to output for context
|
||||
resultContent = selectedLines
|
||||
.map((line, idx) => `${String(start + idx).padStart(5)}│ ${line}`)
|
||||
.join('\n');
|
||||
} else {
|
||||
// No range specified - return full content but warn if large
|
||||
const MAX_LINES_WITHOUT_RANGE = 500;
|
||||
if (totalLines > MAX_LINES_WITHOUT_RANGE) {
|
||||
// Return first portion with warning
|
||||
const selectedLines = lines.slice(0, MAX_LINES_WITHOUT_RANGE);
|
||||
resultContent = selectedLines
|
||||
.map((line, idx) => `${String(idx + 1).padStart(5)}│ ${line}`)
|
||||
.join('\n');
|
||||
resultContent += `\n\n[... ${totalLines - MAX_LINES_WITHOUT_RANGE} more lines. Use startLine/endLine to read specific ranges.]`;
|
||||
resultEndLine = MAX_LINES_WITHOUT_RANGE;
|
||||
} else {
|
||||
resultContent = contentStr;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
content: resultContent,
|
||||
encoding,
|
||||
totalLines,
|
||||
startLine: resultStartLine,
|
||||
endLine: resultEndLine,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'write': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const encoding = (params.encoding as string) || 'utf8';
|
||||
await this.smartfs
|
||||
.file(validatedPath)
|
||||
.encoding(encoding as 'utf8' | 'binary' | 'base64')
|
||||
.write(params.content as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
written: true,
|
||||
bytesWritten: (params.content as string).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
await this.smartfs.file(validatedPath).append(params.content as string);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
appended: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
let dir = this.smartfs.directory(validatedPath);
|
||||
if (params.recursive) {
|
||||
dir = dir.recursive();
|
||||
}
|
||||
if (params.filter) {
|
||||
dir = dir.filter(params.filter as string);
|
||||
}
|
||||
let entries = await dir.list();
|
||||
|
||||
// Filter out excluded paths
|
||||
if (this.excludePatterns.length > 0) {
|
||||
entries = entries.filter(entry => {
|
||||
const relativePath = plugins.path.relative(validatedPath, entry.path);
|
||||
return !this.isExcluded(relativePath) && !this.isExcluded(entry.name);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
entries,
|
||||
count: entries.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
// Check if it's a directory or file
|
||||
const exists = await this.smartfs.file(validatedPath).exists();
|
||||
if (exists) {
|
||||
// Try to get stats to check if it's a directory
|
||||
try {
|
||||
const stats = await this.smartfs.file(validatedPath).stat();
|
||||
if (stats.isDirectory && params.recursive) {
|
||||
await this.smartfs.directory(validatedPath).recursive().delete();
|
||||
} else {
|
||||
await this.smartfs.file(validatedPath).delete();
|
||||
}
|
||||
} catch {
|
||||
await this.smartfs.file(validatedPath).delete();
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
deleted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'exists': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const exists = await this.smartfs.file(validatedPath).exists();
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
exists,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'stat': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const stats = await this.smartfs.file(validatedPath).stat();
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'copy': {
|
||||
const validatedSource = this.validatePath(params.source as string);
|
||||
const validatedDest = this.validatePath(params.destination as string);
|
||||
await this.smartfs.file(validatedSource).copy(validatedDest);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
source: params.source,
|
||||
destination: params.destination,
|
||||
copied: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
const validatedSource = this.validatePath(params.source as string);
|
||||
const validatedDest = this.validatePath(params.destination as string);
|
||||
await this.smartfs.file(validatedSource).move(validatedDest);
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
source: params.source,
|
||||
destination: params.destination,
|
||||
moved: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'mkdir': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
let dir = this.smartfs.directory(validatedPath);
|
||||
if (params.recursive !== false) {
|
||||
dir = dir.recursive();
|
||||
}
|
||||
await dir.create();
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
created: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tree': {
|
||||
const validatedPath = this.validatePath(params.path as string);
|
||||
const maxDepth = (params.maxDepth as number) ?? 3;
|
||||
const filter = params.filter as string | undefined;
|
||||
const showSizes = (params.showSizes as boolean) ?? false;
|
||||
const format = (params.format as 'string' | 'json') ?? 'string';
|
||||
|
||||
// Collect all entries recursively up to maxDepth
|
||||
interface ITreeEntry {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
isDir: boolean;
|
||||
depth: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const entries: ITreeEntry[] = [];
|
||||
|
||||
const collectEntries = async (dirPath: string, depth: number, relativePath: string) => {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
let dir = this.smartfs.directory(dirPath);
|
||||
if (filter) {
|
||||
dir = dir.filter(filter);
|
||||
}
|
||||
const items = await dir.list();
|
||||
|
||||
for (const item of items) {
|
||||
// item is IDirectoryEntry with name, path, isFile, isDirectory properties
|
||||
const itemPath = item.path;
|
||||
const itemRelPath = relativePath ? `${relativePath}/${item.name}` : item.name;
|
||||
const isDir = item.isDirectory;
|
||||
|
||||
// Skip excluded paths
|
||||
if (this.isExcluded(itemRelPath) || this.isExcluded(item.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: ITreeEntry = {
|
||||
path: itemPath,
|
||||
relativePath: itemRelPath,
|
||||
isDir,
|
||||
depth,
|
||||
};
|
||||
|
||||
if (showSizes && !isDir && item.stats) {
|
||||
entry.size = item.stats.size;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
|
||||
// Recurse into directories
|
||||
if (isDir && depth < maxDepth) {
|
||||
await collectEntries(itemPath, depth + 1, itemRelPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await collectEntries(validatedPath, 0, '');
|
||||
|
||||
// Sort entries by path for consistent output
|
||||
entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
|
||||
if (format === 'json') {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
entries: entries.map((e) => ({
|
||||
path: e.relativePath,
|
||||
isDir: e.isDir,
|
||||
depth: e.depth,
|
||||
...(e.size !== undefined ? { size: e.size } : {}),
|
||||
})),
|
||||
count: entries.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Format as string tree
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
// Build tree string with proper indentation
|
||||
let treeStr = `${params.path}/\n`;
|
||||
const pathParts = new Map<string, number>(); // Track which paths are last in their parent
|
||||
|
||||
// Group by parent to determine last child
|
||||
const parentChildCount = new Map<string, number>();
|
||||
const parentCurrentChild = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const parentPath = entry.relativePath.includes('/')
|
||||
? entry.relativePath.substring(0, entry.relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
parentChildCount.set(parentPath, (parentChildCount.get(parentPath) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const parentPath = entry.relativePath.includes('/')
|
||||
? entry.relativePath.substring(0, entry.relativePath.lastIndexOf('/'))
|
||||
: '';
|
||||
parentCurrentChild.set(parentPath, (parentCurrentChild.get(parentPath) || 0) + 1);
|
||||
const isLast = parentCurrentChild.get(parentPath) === parentChildCount.get(parentPath);
|
||||
|
||||
// Build prefix based on depth
|
||||
let prefix = '';
|
||||
const parts = entry.relativePath.split('/');
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
prefix += '│ ';
|
||||
}
|
||||
prefix += isLast ? '└── ' : '├── ';
|
||||
|
||||
const name = parts[parts.length - 1];
|
||||
const suffix = entry.isDir ? '/' : '';
|
||||
const sizeStr = showSizes && entry.size !== undefined ? ` (${formatSize(entry.size)})` : '';
|
||||
|
||||
treeStr += `${prefix}${name}${suffix}${sizeStr}\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
path: params.path,
|
||||
tree: treeStr,
|
||||
count: entries.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'glob': {
|
||||
const pattern = params.pattern as string;
|
||||
const basePath = params.path ? this.validatePath(params.path as string) : (this.basePath || process.cwd());
|
||||
|
||||
// Use smartfs to list with filter
|
||||
const dir = this.smartfs.directory(basePath).recursive().filter(pattern);
|
||||
const matches = await dir.list();
|
||||
|
||||
// Return file paths relative to base path for readability
|
||||
// Filter out excluded paths
|
||||
const files = matches
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
relativePath: plugins.path.relative(basePath, entry.path),
|
||||
isDirectory: entry.isDirectory,
|
||||
}))
|
||||
.filter((file) => !this.isExcluded(file.relativePath));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
pattern,
|
||||
basePath,
|
||||
files,
|
||||
count: files.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${action}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public getCallSummary(action: string, params: Record<string, unknown>): string {
|
||||
switch (action) {
|
||||
case 'read': {
|
||||
const lineRange = params.startLine || params.endLine
|
||||
? ` lines ${params.startLine || 1}-${params.endLine || 'end'}`
|
||||
: '';
|
||||
return `Read file "${params.path}"${lineRange}`;
|
||||
}
|
||||
|
||||
case 'write': {
|
||||
const content = params.content as string;
|
||||
const preview = content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
return `Write ${content.length} bytes to "${params.path}". Content preview: "${preview}"`;
|
||||
}
|
||||
|
||||
case 'append': {
|
||||
const content = params.content as string;
|
||||
const preview = content.length > 100 ? content.substring(0, 100) + '...' : content;
|
||||
return `Append ${content.length} bytes to "${params.path}". Content preview: "${preview}"`;
|
||||
}
|
||||
|
||||
case 'list':
|
||||
return `List directory "${params.path}"${params.recursive ? ' recursively' : ''}${params.filter ? ` with filter "${params.filter}"` : ''}`;
|
||||
|
||||
case 'delete':
|
||||
return `Delete "${params.path}"${params.recursive ? ' recursively' : ''}`;
|
||||
|
||||
case 'exists':
|
||||
return `Check if "${params.path}" exists`;
|
||||
|
||||
case 'stat':
|
||||
return `Get statistics for "${params.path}"`;
|
||||
|
||||
case 'copy':
|
||||
return `Copy "${params.source}" to "${params.destination}"`;
|
||||
|
||||
case 'move':
|
||||
return `Move "${params.source}" to "${params.destination}"`;
|
||||
|
||||
case 'mkdir':
|
||||
return `Create directory "${params.path}"${params.recursive !== false ? ' (with parents)' : ''}`;
|
||||
|
||||
case 'tree':
|
||||
return `Show tree of "${params.path}" (depth: ${params.maxDepth ?? 3}, format: ${params.format ?? 'string'})`;
|
||||
|
||||
case 'glob':
|
||||
return `Find files matching "${params.pattern}"${params.path ? ` in "${params.path}"` : ''}`;
|
||||
|
||||
default:
|
||||
return `Unknown action: ${action}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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