Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01df877480 | |||
| f138495edf | |||
| f183bf19ac | |||
| 6fb2b3a61f | |||
| ca56f4c4e8 | |||
| 5ceeddd8bb |
@@ -4,6 +4,36 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 2026-05-15 - 3.5.0
|
||||
|
||||
### Features
|
||||
|
||||
- add reusable execution contexts for shell, filesystem, and browser tools (tools)
|
||||
- introduces shared tool execution context interfaces plus a local Node.js context implementation
|
||||
- adds createShellTools, createFilesystemTools, and createBrowserTools for host-provided transports with permission hooks
|
||||
- re-exports the new tool factories and context types from the main package entrypoint while keeping compatibility wrappers
|
||||
|
||||
## 2026-05-14 - 3.4.0
|
||||
|
||||
### Features
|
||||
|
||||
- add streamed reasoning summary callbacks to runAgent (agent)
|
||||
- Introduces onReasoningStart, onReasoningDelta, and onReasoningEnd callbacks in the agent options interface
|
||||
- Handles reasoning-start, reasoning-delta, and reasoning-end stream chunks while accumulating reasoning text by id
|
||||
- Ensures incomplete reasoning streams are finalized after the response completes
|
||||
- Adds tests for reasoning summary streaming and updates the README API documentation
|
||||
|
||||
## 2026-05-14 - 3.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- upgrade @push.rocks/smartai to ^4.0.0 (deps)
|
||||
- Updates the core smartai dependency from ^2.3.0 to ^4.0.0.
|
||||
- Refreshes README hints to document the new smartai version.
|
||||
|
||||
## 2026-05-14 - 3.2.0
|
||||
|
||||
### Features
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartagent",
|
||||
"version": "3.2.0",
|
||||
"version": "3.5.0",
|
||||
"private": false,
|
||||
"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",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@push.rocks/smartai": "^2.3.0",
|
||||
"@push.rocks/smartai": "^4.0.0",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
"@push.rocks/smartshell": "^3.3.8",
|
||||
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@push.rocks/smartai':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(typescript@6.0.3)(ws@8.20.0)(zod@4.4.1)
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(typescript@6.0.3)(ws@8.20.0)(zod@4.4.1)
|
||||
'@push.rocks/smartfs':
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
@@ -960,8 +960,8 @@ packages:
|
||||
'@push.rocks/qenv@6.1.3':
|
||||
resolution: {integrity: sha512-+z2hsAU/7CIgpYLFqvda8cn9rUBMHqLdQLjsFfRn5jPoD7dJ5rFlpkbhfM4Ws8mHMniwWaxGKo+q/YBhtzRBLg==}
|
||||
|
||||
'@push.rocks/smartai@2.3.0':
|
||||
resolution: {integrity: sha512-i2Oz322qzU0ao/QJvpFNmqN8fkGbctImYZ6iDs9MYwR6KKbwoLDp1tZg1rM/nf1LuHOqjdojGcDNf0ycrTfHTw==}
|
||||
'@push.rocks/smartai@4.0.0':
|
||||
resolution: {integrity: sha512-wl6XZ6uQ/LQrz4fvKIan5zryKo/v+2uYwknXHsunyQ6rK0Nw8MPjQ4QFWLG+8wsaWvgh42WPKcCp6zWcVguvVw==}
|
||||
|
||||
'@push.rocks/smartarchive@4.2.4':
|
||||
resolution: {integrity: sha512-uiqVAXPxmr8G5rv3uZvZFMOCt8l7cZC3nzvsy4YQqKf/VkPhKIEX+b7LkAeNlxPSYUiBQUkNRoawg9+5BaMcHg==}
|
||||
@@ -4933,7 +4933,7 @@ snapshots:
|
||||
'@push.rocks/smartlog': 3.2.2
|
||||
'@push.rocks/smartpath': 6.0.0
|
||||
|
||||
'@push.rocks/smartai@2.3.0(typescript@6.0.3)(ws@8.20.0)(zod@4.4.1)':
|
||||
'@push.rocks/smartai@4.0.0(typescript@6.0.3)(ws@8.20.0)(zod@4.4.1)':
|
||||
dependencies:
|
||||
'@ai-sdk/anthropic': 3.0.77(zod@4.4.1)
|
||||
'@ai-sdk/google': 3.0.73(zod@4.4.1)
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ Each exports a factory returning a flat `ToolSet` (Record<string, Tool>):
|
||||
4. **jsonTool()** → `json_validate`, `json_transform`
|
||||
|
||||
## Key Dependencies
|
||||
- `@push.rocks/smartai` ^2.0.0 — provider registry, `getModel()`, re-exports `tool`, `jsonSchema`
|
||||
- `@push.rocks/smartai` ^4.0.0 — provider registry, `getModel()`, re-exports `tool`, `jsonSchema`
|
||||
- `ai` ^6.0.0 — Vercel AI SDK v6 (`streamText`, `stepCountIs`, `ModelMessage`, `ToolSet`)
|
||||
- `zod` ^3.25.0 — tool input schema definitions
|
||||
- `@push.rocks/smartfs`, `smartshell`, `smartrequest` — tool implementations
|
||||
|
||||
@@ -76,7 +76,7 @@ console.log(result.usage); // { inputTokens, outputTokens, totalTokens, cacheR
|
||||
- ⚡ **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
|
||||
- 📊 **Token and reasoning streaming** — `onToken`, `onReasoning*`, and `onToolCall` callbacks for real-time progress
|
||||
- 💥 **Context overflow handling** — detects overflow and invokes your `onContextOverflow` callback
|
||||
|
||||
## Core API
|
||||
@@ -98,6 +98,9 @@ The single entry point. Options:
|
||||
| `messages` | `ModelMessage[]` | `[]` | Conversation history (for multi-turn) |
|
||||
| `maxRetries` | `number` | `5` | Max retries on rate-limit/server errors |
|
||||
| `onToken` | `(delta: string) => void` | — | Streaming token callback |
|
||||
| `onReasoningStart` | `(id: string) => void` | — | Called when a reasoning summary starts |
|
||||
| `onReasoningDelta` | `(id: string, delta: string) => void` | — | Called for streamed reasoning summary text |
|
||||
| `onReasoningEnd` | `(id: string, text: string) => void` | — | Called when a reasoning summary completes |
|
||||
| `onToolCall` | `(name: string) => void` | — | Called when a tool is invoked |
|
||||
| `onToolResult` | `(name: string, result: unknown) => void` | — | Called when a tool finishes |
|
||||
| `validateCompletion` | `(result) => string \| void` | — | Return a string to reject and reprompt an incomplete run |
|
||||
@@ -239,6 +242,36 @@ await runAgent({
|
||||
});
|
||||
```
|
||||
|
||||
## Reusable Tool Contexts
|
||||
|
||||
SmartAgent can build tools once and execute them through a host-provided context. The same shell, filesystem, and browser tool schemas can target local Node.js, SSH, MCP, or another transport supplied by the host app.
|
||||
|
||||
```typescript
|
||||
import { createFilesystemTools, createShellTools, type IToolExecutionContext } from '@push.rocks/smartagent';
|
||||
|
||||
const context: IToolExecutionContext = {
|
||||
cwd: '/workspace/project',
|
||||
requestPermission: async (request) => {
|
||||
// Host app decides whether to allow writes, commands, browser actions, etc.
|
||||
},
|
||||
shell: {
|
||||
run: async (command, options) => sshRun(command, options),
|
||||
},
|
||||
fs: {
|
||||
readFile: async (path, options) => sshRead(path, options),
|
||||
writeFile: async (path, content) => sshWrite(path, content),
|
||||
listDirectory: async (path, options) => sshList(path, options),
|
||||
},
|
||||
};
|
||||
|
||||
const tools = {
|
||||
...createShellTools(context),
|
||||
...createFilesystemTools(context, { includeDelete: false }),
|
||||
};
|
||||
```
|
||||
|
||||
For local execution, use `createLocalToolExecutionContext()` or the compatibility wrappers `shellTool()` and `filesystemTool()`.
|
||||
|
||||
## ToolRegistry
|
||||
|
||||
A lightweight helper for collecting tools:
|
||||
|
||||
+126
-1
@@ -1,7 +1,7 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MockLanguageModelV3, convertArrayToReadableStream } from 'ai/test';
|
||||
import * as smartagent from '../ts/index.js';
|
||||
import { filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
|
||||
import { createBrowserTools, createFilesystemTools, createShellTools, filesystemTool, shellTool, httpTool, jsonTool, truncateOutput } from '../ts_tools/index.js';
|
||||
import { compactMessages } from '../ts_compaction/index.js';
|
||||
|
||||
const createUsage = (inputTokens: number, outputTokens: number) => ({
|
||||
@@ -33,6 +33,25 @@ const createTextStreamResult = (text: string) => ({
|
||||
] as any[]),
|
||||
});
|
||||
|
||||
const createReasoningStreamResult = (reasoning: string, text: string) => ({
|
||||
stream: convertArrayToReadableStream([
|
||||
{ type: 'stream-start', warnings: [] },
|
||||
{ type: 'response-metadata', id: 'response-1', timestamp: new Date(0), modelId: 'mock-model' },
|
||||
{ type: 'reasoning-start', id: 'reasoning-1' },
|
||||
{ type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(0, 7) },
|
||||
{ type: 'reasoning-delta', id: 'reasoning-1', delta: reasoning.slice(7) },
|
||||
{ type: 'reasoning-end', id: 'reasoning-1' },
|
||||
{ type: 'text-start', id: 'text-1' },
|
||||
{ type: 'text-delta', id: 'text-1', delta: text },
|
||||
{ type: 'text-end', id: 'text-1' },
|
||||
{
|
||||
type: 'finish',
|
||||
finishReason: { unified: 'stop', raw: 'stop' },
|
||||
usage: createUsage(2, 2),
|
||||
},
|
||||
] as any[]),
|
||||
});
|
||||
|
||||
const createToolCallStreamResult = (toolName: string, input: unknown) => ({
|
||||
stream: convertArrayToReadableStream([
|
||||
{ type: 'stream-start', warnings: [] },
|
||||
@@ -131,6 +150,32 @@ tap.test('runAgent should add OpenAI cache defaults when sessionId is provided',
|
||||
expect(openaiOptions.reasoningEffort).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('runAgent should stream reasoning summary callbacks', async () => {
|
||||
const reasoningEvents: string[] = [];
|
||||
const tokenDeltas: string[] = [];
|
||||
const model = new MockLanguageModelV3({
|
||||
doStream: async () => createReasoningStreamResult('thinking through it', 'done') as any,
|
||||
});
|
||||
|
||||
const result = await smartagent.runAgent({
|
||||
model,
|
||||
prompt: 'hello',
|
||||
onToken: (delta) => tokenDeltas.push(delta),
|
||||
onReasoningStart: (id) => reasoningEvents.push('start:' + id),
|
||||
onReasoningDelta: (id, delta) => reasoningEvents.push('delta:' + id + ':' + delta),
|
||||
onReasoningEnd: (id, text) => reasoningEvents.push('end:' + id + ':' + text),
|
||||
});
|
||||
|
||||
expect(result.text).toEqual('done');
|
||||
expect(tokenDeltas.join('')).toEqual('done');
|
||||
expect(reasoningEvents).toEqual([
|
||||
'start:reasoning-1',
|
||||
'delta:reasoning-1:thinkin',
|
||||
'delta:reasoning-1:g through it',
|
||||
'end:reasoning-1:thinking through it',
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('runAgent should mark Anthropic prompt cache breakpoints by default', async () => {
|
||||
const model = new MockLanguageModelV3({
|
||||
provider: 'anthropic',
|
||||
@@ -312,6 +357,29 @@ tap.test('shellTool returns expected tool names', async () => {
|
||||
expect(names).toContain('run_command');
|
||||
});
|
||||
|
||||
tap.test('createShellTools should execute through supplied context', async () => {
|
||||
const permissions: unknown[] = [];
|
||||
const calls: unknown[] = [];
|
||||
const tools = createShellTools({
|
||||
cwd: '/workspace',
|
||||
requestPermission: async (request) => {
|
||||
permissions.push(request);
|
||||
},
|
||||
shell: {
|
||||
run: async (command, options) => {
|
||||
calls.push({ command, options });
|
||||
return { exitCode: 0, stdout: 'context-output', stderr: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (tools.run_command as any).execute({ command: 'echo test', timeoutMs: 1234 });
|
||||
|
||||
expect(result).toEqual('context-output');
|
||||
expect(calls[0]).toEqual({ command: 'echo test', options: { cwd: '/workspace', timeoutMs: 1234, abortSignal: undefined } });
|
||||
expect(JSON.stringify(permissions[0])).toInclude('echo test');
|
||||
});
|
||||
|
||||
tap.test('httpTool returns expected tool names', async () => {
|
||||
const tools = httpTool();
|
||||
const names = Object.keys(tools);
|
||||
@@ -326,6 +394,63 @@ tap.test('jsonTool returns expected tool names', async () => {
|
||||
expect(names).toContain('json_transform');
|
||||
});
|
||||
|
||||
tap.test('createFilesystemTools should execute through supplied context', async () => {
|
||||
const permissions: unknown[] = [];
|
||||
const files = new Map<string, string>([['hello.txt', 'line1\nline2\nline3']]);
|
||||
const tools = createFilesystemTools({
|
||||
requestPermission: async (request) => {
|
||||
permissions.push(request);
|
||||
},
|
||||
fs: {
|
||||
readFile: async (filePath, options) => {
|
||||
const content = files.get(filePath) ?? '';
|
||||
if (options?.startLine || options?.endLine) {
|
||||
const lines = content.split('\n');
|
||||
return lines.slice((options.startLine ?? 1) - 1, options.endLine ?? lines.length).join('\n');
|
||||
}
|
||||
return content;
|
||||
},
|
||||
writeFile: async (filePath, content) => {
|
||||
files.set(filePath, content);
|
||||
return 'written';
|
||||
},
|
||||
listDirectory: async () => [...files.keys()],
|
||||
},
|
||||
}, { includeDelete: false });
|
||||
|
||||
const readResult = await (tools.read_file as any).execute({ path: 'hello.txt', startLine: 2, endLine: 2 });
|
||||
const writeResult = await (tools.write_file as any).execute({ path: 'created.txt', content: 'created' });
|
||||
const listResult = await (tools.list_directory as any).execute({ path: '.' });
|
||||
|
||||
expect(readResult).toEqual('line2');
|
||||
expect(writeResult).toEqual('written');
|
||||
expect(listResult).toInclude('created.txt');
|
||||
expect(Object.keys(tools)).not.toContain('delete_file');
|
||||
expect(JSON.stringify(permissions[0])).toInclude('created.txt');
|
||||
});
|
||||
|
||||
tap.test('createBrowserTools should execute through supplied browser context', async () => {
|
||||
const permissions: unknown[] = [];
|
||||
const calls: unknown[] = [];
|
||||
const tools = createBrowserTools({
|
||||
requestPermission: async (request) => {
|
||||
permissions.push(request);
|
||||
},
|
||||
browser: {
|
||||
execute: async (input, options) => {
|
||||
calls.push({ input, options });
|
||||
return `browser:${input.action}:${input.url ?? ''}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (tools.browser as any).execute({ action: 'navigate', url: 'https://example.com', timeoutMs: 500 });
|
||||
|
||||
expect(result).toEqual('browser:navigate:https://example.com');
|
||||
expect(JSON.stringify(calls[0])).toInclude('navigate');
|
||||
expect(JSON.stringify(permissions[0])).toInclude('https://example.com');
|
||||
});
|
||||
|
||||
tap.test('json_validate tool should validate valid JSON', async () => {
|
||||
const tools = jsonTool();
|
||||
const result = await (tools.json_validate as any).execute({
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartagent',
|
||||
version: '3.2.0',
|
||||
version: '3.5.0',
|
||||
description: 'Agentic loop for ai-sdk (Vercel AI SDK). Wraps streamText with stopWhen for parallel multi-step tool execution. Built on @push.rocks/smartai.'
|
||||
}
|
||||
|
||||
+29
@@ -12,6 +12,35 @@ export type {
|
||||
TAgentCacheRetention,
|
||||
TAgentCacheSetting,
|
||||
} from './smartagent.interfaces.js';
|
||||
export {
|
||||
createBrowserTools,
|
||||
createFilesystemTools,
|
||||
createLocalToolExecutionContext,
|
||||
createShellTools,
|
||||
filesystemTool,
|
||||
formatShellResult,
|
||||
formatToolOutput,
|
||||
shellTool,
|
||||
} from '../ts_tools/index.js';
|
||||
export type {
|
||||
IBrowserToolInput,
|
||||
ICreateBrowserToolsOptions,
|
||||
ICreateFilesystemToolsOptions,
|
||||
ICreateShellToolsOptions,
|
||||
IFilesystemToolOptions,
|
||||
ILocalToolExecutionContextOptions,
|
||||
IShellToolOptions,
|
||||
IToolBrowserContext,
|
||||
IToolExecutionContext,
|
||||
IToolFilesystemContext,
|
||||
IToolFilesystemListOptions,
|
||||
IToolFilesystemReadOptions,
|
||||
IToolPermissionRequest,
|
||||
IToolRunOptions,
|
||||
IToolShellContext,
|
||||
IToolShellResult,
|
||||
TBrowserToolAction,
|
||||
} from '../ts_tools/index.js';
|
||||
|
||||
// Re-export tool() and z so consumers can define tools without extra imports
|
||||
export { tool, jsonSchema } from '@push.rocks/smartai';
|
||||
|
||||
@@ -156,6 +156,7 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
|
||||
let validationRetries = 0;
|
||||
const toolCalls: IAgentToolCallRecord[] = [];
|
||||
const toolCallIndexes = new Map<string, number>();
|
||||
const reasoningTextById = new Map<string, string>();
|
||||
|
||||
const tools = options.tools ?? {};
|
||||
const cache = options.cache ?? 'auto';
|
||||
@@ -227,8 +228,33 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
|
||||
},
|
||||
|
||||
onChunk: ({ chunk }) => {
|
||||
if (chunk.type === 'text-delta' && options.onToken) {
|
||||
options.onToken((chunk as any).textDelta ?? (chunk as any).text ?? '');
|
||||
const chunkType = String((chunk as any).type || '');
|
||||
if (chunkType === 'text-delta' && options.onToken) {
|
||||
options.onToken((chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? '');
|
||||
return;
|
||||
}
|
||||
if (chunkType === 'reasoning-start') {
|
||||
const id = (chunk as any).id || 'reasoning';
|
||||
reasoningTextById.set(id, '');
|
||||
options.onReasoningStart?.(id, (chunk as any).providerMetadata);
|
||||
return;
|
||||
}
|
||||
if (chunkType === 'reasoning-delta') {
|
||||
const id = (chunk as any).id || 'reasoning';
|
||||
const delta = (chunk as any).delta ?? (chunk as any).textDelta ?? (chunk as any).text ?? '';
|
||||
if (!reasoningTextById.has(id)) {
|
||||
reasoningTextById.set(id, '');
|
||||
options.onReasoningStart?.(id, (chunk as any).providerMetadata);
|
||||
}
|
||||
reasoningTextById.set(id, (reasoningTextById.get(id) ?? '') + delta);
|
||||
options.onReasoningDelta?.(id, delta, (chunk as any).providerMetadata);
|
||||
return;
|
||||
}
|
||||
if (chunkType === 'reasoning-end') {
|
||||
const id = (chunk as any).id || 'reasoning';
|
||||
const text = reasoningTextById.get(id) ?? '';
|
||||
reasoningTextById.delete(id);
|
||||
options.onReasoningEnd?.(id, text, (chunk as any).providerMetadata);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -286,6 +312,10 @@ export async function runAgent(options: IAgentRunOptions): Promise<IAgentRunResu
|
||||
const finishReason = await result.finishReason;
|
||||
const responseData = await result.response;
|
||||
const responseMessages = responseData.messages as plugins.ModelMessage[];
|
||||
for (const [id, reasoningText] of reasoningTextById) {
|
||||
options.onReasoningEnd?.(id, reasoningText);
|
||||
reasoningTextById.delete(id);
|
||||
}
|
||||
|
||||
attempt = 0; // reset on success
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface IAgentRunOptions {
|
||||
messages?: ModelMessage[];
|
||||
/** Called for each streamed text delta */
|
||||
onToken?: (delta: string) => void;
|
||||
/** Called when the model starts a streamed reasoning summary */
|
||||
onReasoningStart?: (id: string, providerMetadata?: unknown) => void;
|
||||
/** Called for each streamed reasoning summary delta */
|
||||
onReasoningDelta?: (id: string, delta: string, providerMetadata?: unknown) => void;
|
||||
/** Called when a streamed reasoning summary completes */
|
||||
onReasoningEnd?: (id: string, text: string, providerMetadata?: unknown) => void;
|
||||
/** Called when a tool call starts */
|
||||
onToolCall?: (toolName: string, input: unknown) => void;
|
||||
/** Called when a tool call completes */
|
||||
|
||||
+21
-4
@@ -1,7 +1,24 @@
|
||||
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 { createBrowserTools } from './tool.browser.js';
|
||||
export type { ICreateBrowserToolsOptions } from './tool.browser.js';
|
||||
export { createFilesystemTools, filesystemTool } from './tool.filesystem.js';
|
||||
export type { ICreateFilesystemToolsOptions, IFilesystemToolOptions } from './tool.filesystem.js';
|
||||
export { createShellTools, shellTool } from './tool.shell.js';
|
||||
export type { ICreateShellToolsOptions, IShellToolOptions } from './tool.shell.js';
|
||||
export { createLocalToolExecutionContext, formatShellResult, formatToolOutput } from './tool.context.js';
|
||||
export type {
|
||||
IBrowserToolInput,
|
||||
ILocalToolExecutionContextOptions,
|
||||
IToolBrowserContext,
|
||||
IToolExecutionContext,
|
||||
IToolFilesystemContext,
|
||||
IToolFilesystemListOptions,
|
||||
IToolFilesystemReadOptions,
|
||||
IToolPermissionRequest,
|
||||
IToolRunOptions,
|
||||
IToolShellContext,
|
||||
IToolShellResult,
|
||||
TBrowserToolAction,
|
||||
} from './tool.context.js';
|
||||
export { httpTool } from './tool.http.js';
|
||||
export { jsonTool } from './tool.json.js';
|
||||
export { truncateOutput } from './plugins.js';
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
// node native
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export { path, fs };
|
||||
export { childProcess, fs, path };
|
||||
|
||||
// zod
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
formatToolOutput,
|
||||
type IBrowserToolInput,
|
||||
type IToolExecutionContext,
|
||||
type TBrowserToolAction,
|
||||
} from './tool.context.js';
|
||||
|
||||
export interface ICreateBrowserToolsOptions {
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
const browserActions = new Set<TBrowserToolAction>(['navigate', 'snapshot', 'screenshot', 'click', 'fill', 'press', 'evaluate', 'close']);
|
||||
|
||||
export function createBrowserTools(context: IToolExecutionContext, options: ICreateBrowserToolsOptions = {}): plugins.ToolSet {
|
||||
return {
|
||||
browser: plugins.tool({
|
||||
description: [
|
||||
'Control a browser supplied by the host execution context for web UI inspection and interaction.',
|
||||
'Actions: navigate, snapshot, screenshot, click, fill, press, evaluate, close.',
|
||||
'Use snapshot after navigation or interaction to inspect page text and interactive selectors before choosing the next action.',
|
||||
'Actions that navigate or modify page state require host permission when configured.',
|
||||
].join(' '),
|
||||
inputSchema: plugins.z.object({
|
||||
action: plugins.z.string().default('snapshot').describe('Action: navigate, snapshot, screenshot, click, fill, press, evaluate, or close'),
|
||||
url: plugins.z.string().optional().describe('URL for navigate'),
|
||||
selector: plugins.z.string().optional().describe('CSS or Playwright selector for click/fill'),
|
||||
text: plugins.z.string().optional().describe('Text for fill, key name for press, or screenshot mode/full-page hint'),
|
||||
script: plugins.z.string().optional().describe('JavaScript expression or function body for evaluate'),
|
||||
timeoutMs: plugins.z.number().optional().describe('Optional action timeout in milliseconds'),
|
||||
}),
|
||||
execute: async (input: IBrowserToolInput) => {
|
||||
if (!context.browser) {
|
||||
throw new Error('Browser tool is not available in this execution context.');
|
||||
}
|
||||
const action = normalizeBrowserAction(input.action);
|
||||
await requestBrowserPermission(context, { ...input, action });
|
||||
const result = await context.browser.execute({ ...input, action }, {
|
||||
timeoutMs: input.timeoutMs,
|
||||
abortSignal: context.abortSignal,
|
||||
});
|
||||
return plugins.truncateOutput(formatToolOutput(result), {
|
||||
maxLines: options.maxLines,
|
||||
maxBytes: options.maxBytes,
|
||||
}).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizeBrowserAction = (input: unknown): TBrowserToolAction => {
|
||||
const action = typeof input === 'string' && input.trim()
|
||||
? input.trim().toLowerCase()
|
||||
: 'snapshot';
|
||||
if (browserActions.has(action as TBrowserToolAction)) {
|
||||
return action as TBrowserToolAction;
|
||||
}
|
||||
throw new Error(`Unsupported browser action: ${String(input)}. Use one of: ${[...browserActions].join(', ')}.`);
|
||||
};
|
||||
|
||||
const requestBrowserPermission = async (context: IToolExecutionContext, input: IBrowserToolInput & { action: TBrowserToolAction }): Promise<void> => {
|
||||
if (!context.requestPermission) return;
|
||||
if (input.action === 'snapshot' || input.action === 'screenshot') return;
|
||||
const titleByAction: Record<TBrowserToolAction, string> = {
|
||||
navigate: 'Navigate browser',
|
||||
snapshot: 'Inspect browser',
|
||||
screenshot: 'Capture browser screenshot',
|
||||
click: 'Click browser element',
|
||||
fill: 'Fill browser element',
|
||||
press: 'Press browser key',
|
||||
evaluate: 'Evaluate browser JavaScript',
|
||||
close: 'Close browser session',
|
||||
};
|
||||
await context.requestPermission({
|
||||
type: 'browser',
|
||||
title: titleByAction[input.action],
|
||||
metadata: {
|
||||
action: input.action,
|
||||
url: input.url,
|
||||
selector: input.selector,
|
||||
key: input.action === 'press' ? input.text : undefined,
|
||||
textLength: input.action === 'fill' ? input.text?.length ?? 0 : undefined,
|
||||
scriptPreview: input.action === 'evaluate' && input.script ? compactMetadataText(input.script) : undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const compactMetadataText = (text: string): string => {
|
||||
const compacted = text.replace(/\s+/g, ' ').trim();
|
||||
return compacted.length > 160 ? `${compacted.slice(0, 157)}...` : compacted;
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
export interface IToolPermissionRequest {
|
||||
type: string;
|
||||
title: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IToolRunOptions {
|
||||
cwd?: string;
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface IToolShellResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr?: string;
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface IToolShellContext {
|
||||
run(command: string, options?: IToolRunOptions): Promise<IToolShellResult | string>;
|
||||
}
|
||||
|
||||
export interface IToolFilesystemReadOptions {
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
|
||||
export interface IToolFilesystemListOptions {
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface IToolFilesystemContext {
|
||||
readFile(filePath: string, options?: IToolFilesystemReadOptions): Promise<string>;
|
||||
writeFile(filePath: string, content: string): Promise<string | void>;
|
||||
listDirectory(directoryPath: string, options?: IToolFilesystemListOptions): Promise<string[] | string>;
|
||||
deletePath?(targetPath: string): Promise<string | void>;
|
||||
}
|
||||
|
||||
export type TBrowserToolAction = 'navigate' | 'snapshot' | 'screenshot' | 'click' | 'fill' | 'press' | 'evaluate' | 'close';
|
||||
|
||||
export interface IBrowserToolInput {
|
||||
action?: TBrowserToolAction | string;
|
||||
url?: string;
|
||||
selector?: string;
|
||||
text?: string;
|
||||
script?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface IToolBrowserContext {
|
||||
execute(input: IBrowserToolInput, options?: { timeoutMs?: number; abortSignal?: AbortSignal }): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface IToolExecutionContext {
|
||||
cwd?: string;
|
||||
rootDir?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
shell?: IToolShellContext;
|
||||
fs?: IToolFilesystemContext;
|
||||
browser?: IToolBrowserContext;
|
||||
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ILocalToolExecutionContextOptions {
|
||||
cwd?: string;
|
||||
rootDir?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
requestPermission?: (request: IToolPermissionRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export const createLocalToolExecutionContext = (options: ILocalToolExecutionContextOptions = {}): IToolExecutionContext => {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const rootDir = options.rootDir;
|
||||
return {
|
||||
cwd,
|
||||
rootDir,
|
||||
abortSignal: options.abortSignal,
|
||||
requestPermission: options.requestPermission,
|
||||
shell: {
|
||||
run: (command, runOptions) => runLocalShellCommand(command, {
|
||||
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
|
||||
timeoutMs: runOptions?.timeoutMs,
|
||||
abortSignal: runOptions?.abortSignal ?? options.abortSignal,
|
||||
}),
|
||||
},
|
||||
fs: {
|
||||
readFile: async (filePath, readOptions) => {
|
||||
const resolved = resolveLocalPath(filePath, rootDir, cwd);
|
||||
const content = await plugins.fs.promises.readFile(resolved, 'utf8');
|
||||
if (readOptions?.startLine !== undefined || readOptions?.endLine !== undefined) {
|
||||
const lines = content.split('\n');
|
||||
const start = Math.max((readOptions.startLine ?? 1) - 1, 0);
|
||||
const end = Math.max(readOptions.endLine ?? lines.length, start);
|
||||
return lines.slice(start, end).join('\n');
|
||||
}
|
||||
return content;
|
||||
},
|
||||
writeFile: async (filePath, content) => {
|
||||
const resolved = resolveLocalPath(filePath, rootDir, cwd);
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(resolved), { recursive: true });
|
||||
await plugins.fs.promises.writeFile(resolved, content, 'utf8');
|
||||
return `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`;
|
||||
},
|
||||
listDirectory: async (directoryPath, listOptions) => {
|
||||
const resolved = resolveLocalPath(directoryPath, rootDir, cwd);
|
||||
return listLocalDirectory(resolved, !!listOptions?.recursive);
|
||||
},
|
||||
deletePath: async (targetPath) => {
|
||||
const resolved = resolveLocalPath(targetPath, rootDir, cwd);
|
||||
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
|
||||
return `Deleted ${targetPath}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const formatShellResult = (result: IToolShellResult | string): string => {
|
||||
if (typeof result === 'string') return result;
|
||||
if (result.exitCode === 0) return result.stdout;
|
||||
return [
|
||||
`Exit code: ${result.exitCode}`,
|
||||
result.signal ? `Signal: ${result.signal}` : '',
|
||||
`stdout:\n${result.stdout}`,
|
||||
`stderr:\n${result.stderr ?? ''}`,
|
||||
].filter(Boolean).join('\n');
|
||||
};
|
||||
|
||||
export const formatToolOutput = (output: unknown): string => {
|
||||
if (typeof output === 'string') return output;
|
||||
try {
|
||||
return JSON.stringify(output, undefined, 2);
|
||||
} catch {
|
||||
return String(output);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveLocalPath = (targetPath: string, rootDir?: string, baseDir?: string): string => {
|
||||
const base = rootDir ?? baseDir ?? process.cwd();
|
||||
const resolved = plugins.path.isAbsolute(targetPath)
|
||||
? plugins.path.resolve(targetPath)
|
||||
: plugins.path.resolve(base, targetPath);
|
||||
if (rootDir) {
|
||||
const resolvedRoot = plugins.path.resolve(rootDir);
|
||||
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + plugins.path.sep)) {
|
||||
throw new Error(`Access denied: "${targetPath}" is outside allowed root "${rootDir}"`);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const listLocalDirectory = async (directoryPath: string, recursive: boolean): Promise<string[]> => {
|
||||
const entries = await plugins.fs.promises.readdir(directoryPath, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const relativePath = entry.name + (entry.isDirectory() ? '/' : '');
|
||||
result.push(relativePath);
|
||||
if (recursive && entry.isDirectory()) {
|
||||
const childEntries = await listLocalDirectory(plugins.path.join(directoryPath, entry.name), true);
|
||||
result.push(...childEntries.map((childEntry) => `${entry.name}/${childEntry}`));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const runLocalShellCommand = async (command: string, options: IToolRunOptions): Promise<IToolShellResult> => {
|
||||
return new Promise<IToolShellResult>((resolve) => {
|
||||
const child = plugins.childProcess.spawn('bash', ['-lc', command], {
|
||||
cwd: options.cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
const timeout = options.timeoutMs && options.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGTERM');
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
const abort = () => child.kill('SIGTERM');
|
||||
options.abortSignal?.addEventListener('abort', abort, { once: true });
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
options.abortSignal?.removeEventListener('abort', abort);
|
||||
resolve({ exitCode: 1, stdout, stderr: `${stderr}${error.message}` });
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
options.abortSignal?.removeEventListener('abort', abort);
|
||||
resolve({
|
||||
exitCode: code ?? (timedOut ? 124 : 1),
|
||||
stdout,
|
||||
stderr: timedOut ? `${stderr}\nCommand timed out after ${options.timeoutMs}ms.`.trim() : stderr,
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
+80
-78
@@ -1,30 +1,43 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
createLocalToolExecutionContext,
|
||||
formatToolOutput,
|
||||
type IToolExecutionContext,
|
||||
} from './tool.context.js';
|
||||
|
||||
export interface IFilesystemToolOptions {
|
||||
/** Restrict file access to this directory. Default: process.cwd() */
|
||||
rootDir?: string;
|
||||
/** Execution context. Defaults to a local Node.js context. */
|
||||
context?: IToolExecutionContext;
|
||||
/** Include delete_file. Default: true for compatibility. */
|
||||
includeDelete?: boolean;
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
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 interface ICreateFilesystemToolsOptions {
|
||||
/** Include delete_file. Default: true. */
|
||||
includeDelete?: boolean;
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
|
||||
const rootDir = options?.rootDir;
|
||||
|
||||
return {
|
||||
export function createFilesystemTools(context: IToolExecutionContext, options: ICreateFilesystemToolsOptions = {}): plugins.ToolSet {
|
||||
const truncate = (output: unknown) => plugins.truncateOutput(formatToolOutput(output), {
|
||||
maxLines: options.maxLines,
|
||||
maxBytes: options.maxBytes,
|
||||
}).content;
|
||||
const tools: plugins.ToolSet = {
|
||||
read_file: plugins.tool({
|
||||
description:
|
||||
'Read file contents. Returns the full text or a specified line range.',
|
||||
'Read a UTF-8 file in the active workspace. Paths may be absolute or relative to the workspace root.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
path: plugins.z.string().describe('File path'),
|
||||
startLine: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
@@ -43,89 +56,78 @@ export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSe
|
||||
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;
|
||||
if (!context.fs) {
|
||||
throw new Error('Filesystem tools are not available in this execution context.');
|
||||
}
|
||||
|
||||
return plugins.truncateOutput(content).content;
|
||||
return truncate(await context.fs.readFile(filePath, { startLine, endLine }));
|
||||
},
|
||||
}),
|
||||
|
||||
write_file: plugins.tool({
|
||||
description:
|
||||
'Write content to a file (creates parent dirs if needed, overwrites existing).',
|
||||
'Write UTF-8 content to a file in the active workspace. Creates parent directories and overwrites existing content. Requires host permission when configured.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Absolute path to the file'),
|
||||
content: plugins.z.string().describe('Content to write'),
|
||||
path: plugins.z.string().describe('File path'),
|
||||
content: plugins.z.string().describe('Complete file content to write'),
|
||||
}),
|
||||
execute: async ({ path: filePath, content }: { path: string; content: string }) => {
|
||||
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}`;
|
||||
if (!context.fs) {
|
||||
throw new Error('Filesystem tools are not available in this execution context.');
|
||||
}
|
||||
await context.requestPermission?.({
|
||||
type: 'write',
|
||||
title: 'Write file',
|
||||
metadata: { path: filePath, bytes: Buffer.byteLength(content, 'utf8') },
|
||||
});
|
||||
const result = await context.fs.writeFile(filePath, content);
|
||||
return truncate(result ?? `Written ${Buffer.byteLength(content, 'utf8')} bytes to ${filePath}`);
|
||||
},
|
||||
}),
|
||||
|
||||
list_directory: plugins.tool({
|
||||
description: 'List files and directories at the given path.',
|
||||
description: 'List files and directories in the active workspace. Paths may be absolute or relative to the workspace root.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Directory path to list'),
|
||||
path: plugins.z.string().default('.').describe('Directory path to list'),
|
||||
recursive: plugins.z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('List recursively (default: false)'),
|
||||
.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;
|
||||
execute: async ({ path: directoryPath, recursive }: { path: string; recursive?: boolean }) => {
|
||||
if (!context.fs) {
|
||||
throw new Error('Filesystem tools are not available in this execution context.');
|
||||
}
|
||||
|
||||
const 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}`;
|
||||
const result = await context.fs.listDirectory(directoryPath, { recursive });
|
||||
return truncate(Array.isArray(result) ? result.join('\n') : result);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (options.includeDelete !== false) {
|
||||
tools.delete_file = plugins.tool({
|
||||
description: 'Delete a file or empty directory in the active workspace. Requires host permission when configured.',
|
||||
inputSchema: plugins.z.object({
|
||||
path: plugins.z.string().describe('Path to delete'),
|
||||
}),
|
||||
execute: async ({ path: targetPath }: { path: string }) => {
|
||||
if (!context.fs?.deletePath) {
|
||||
throw new Error('Deleting files is not available in this execution context.');
|
||||
}
|
||||
await context.requestPermission?.({
|
||||
type: 'delete',
|
||||
title: 'Delete file',
|
||||
metadata: { path: targetPath },
|
||||
});
|
||||
const result = await context.fs.deletePath(targetPath);
|
||||
return truncate(result ?? `Deleted ${targetPath}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function filesystemTool(options?: IFilesystemToolOptions): plugins.ToolSet {
|
||||
const context = options?.context ?? createLocalToolExecutionContext({ rootDir: options?.rootDir });
|
||||
return createFilesystemTools(context, options);
|
||||
}
|
||||
|
||||
+51
-17
@@ -1,19 +1,37 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import {
|
||||
createLocalToolExecutionContext,
|
||||
formatShellResult,
|
||||
type IToolExecutionContext,
|
||||
} from './tool.context.js';
|
||||
|
||||
export interface IShellToolOptions {
|
||||
/** Allowed commands whitelist. If empty, all commands are allowed. */
|
||||
allowedCommands?: string[];
|
||||
/** Working directory for shell execution */
|
||||
cwd?: string;
|
||||
/** Execution context. Defaults to a local Node.js context. */
|
||||
context?: IToolExecutionContext;
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
const smartshell = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
||||
export interface ICreateShellToolsOptions {
|
||||
/** Allowed commands whitelist. If empty, all commands are allowed. */
|
||||
allowedCommands?: string[];
|
||||
/** Maximum output lines before truncating. */
|
||||
maxLines?: number;
|
||||
/** Maximum output bytes before truncating. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export function createShellTools(context: IToolExecutionContext, options: ICreateShellToolsOptions = {}): plugins.ToolSet {
|
||||
return {
|
||||
run_command: plugins.tool({
|
||||
description:
|
||||
'Execute a shell command. Provide the full command string. stdout and stderr are returned.',
|
||||
'Execute a shell command in the active workspace. Provide the full command string. stdout and stderr are returned.',
|
||||
inputSchema: plugins.z.object({
|
||||
command: plugins.z.string().describe('The shell command to execute'),
|
||||
cwd: plugins.z
|
||||
@@ -24,39 +42,55 @@ export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timeout in milliseconds'),
|
||||
timeoutMs: plugins.z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Timeout in milliseconds'),
|
||||
}),
|
||||
execute: async ({
|
||||
command,
|
||||
cwd,
|
||||
timeout,
|
||||
timeoutMs,
|
||||
}: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
timeoutMs?: number;
|
||||
}) => {
|
||||
// Validate against allowed commands whitelist
|
||||
if (options?.allowedCommands?.length) {
|
||||
if (!context.shell) {
|
||||
throw new Error('Shell tool is not available in this execution context.');
|
||||
}
|
||||
|
||||
if (options.allowedCommands?.length) {
|
||||
const baseCommand = command.split(/\s+/)[0];
|
||||
if (!options.allowedCommands.includes(baseCommand)) {
|
||||
return `Command "${baseCommand}" is not in the allowed commands list: ${options.allowedCommands.join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Build full command string with cd prefix if cwd specified
|
||||
const effectiveCwd = cwd ?? options?.cwd;
|
||||
const fullCommand = effectiveCwd
|
||||
? `cd ${JSON.stringify(effectiveCwd)} && ${command}`
|
||||
: command;
|
||||
await context.requestPermission?.({
|
||||
type: 'shell',
|
||||
title: 'Run shell command',
|
||||
metadata: { command, cwd: cwd ?? context.cwd },
|
||||
});
|
||||
|
||||
const execResult = await smartshell.exec(fullCommand);
|
||||
const execResult = await context.shell.run(command, {
|
||||
cwd: cwd ?? context.cwd,
|
||||
timeoutMs: timeoutMs ?? timeout,
|
||||
abortSignal: context.abortSignal,
|
||||
});
|
||||
|
||||
const output =
|
||||
execResult.exitCode === 0
|
||||
? execResult.stdout
|
||||
: `Exit code: ${execResult.exitCode}\nstdout:\n${execResult.stdout}\nstderr:\n${execResult.stderr ?? ''}`;
|
||||
|
||||
return plugins.truncateOutput(output).content;
|
||||
return plugins.truncateOutput(formatShellResult(execResult), {
|
||||
maxLines: options.maxLines,
|
||||
maxBytes: options.maxBytes,
|
||||
}).content;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function shellTool(options?: IShellToolOptions): plugins.ToolSet {
|
||||
const context = options?.context ?? createLocalToolExecutionContext({ cwd: options?.cwd });
|
||||
return createShellTools(context, options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user