Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b9a17c882 | |||
| 9957485281 | |||
| cf45add905 | |||
| 7b4184e88d | |||
| 01df877480 | |||
| f138495edf | |||
| f183bf19ac | |||
| 6fb2b3a61f |
@@ -5,6 +5,46 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 2026-05-18 - 3.7.0
|
||||
|
||||
### Features
|
||||
|
||||
- add MCP tool integration via dedicated subpath export (mcp)
|
||||
- introduces a new @push.rocks/smartagent/mcp entrypoint with MCP client, stdio, HTTP, and custom transport support
|
||||
- adds MCP tool discovery, name sanitization, multi-server prefixing, result formatting, and cleanup handling
|
||||
- documents MCP usage in the README and adds test coverage for tool exposure, pagination, and result formatting
|
||||
|
||||
## 2026-05-15 - 3.6.0
|
||||
|
||||
### Features
|
||||
|
||||
- document provider prompt caching behavior and SmartAI cache integration (readme)
|
||||
- Adds provider prompt caching to the feature list in the README
|
||||
- Clarifies that provider-specific cache metadata is centralized in @push.rocks/smartai
|
||||
|
||||
## 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
|
||||
|
||||
+7
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartagent",
|
||||
"version": "3.3.0",
|
||||
"version": "3.7.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",
|
||||
@@ -18,6 +18,10 @@
|
||||
"./compaction": {
|
||||
"import": "./dist_ts_compaction/index.js",
|
||||
"types": "./dist_ts_compaction/index.d.ts"
|
||||
},
|
||||
"./mcp": {
|
||||
"import": "./dist_ts_mcp/index.js",
|
||||
"types": "./dist_ts_mcp/index.d.ts"
|
||||
}
|
||||
},
|
||||
"author": "Task Venture Capital GmbH",
|
||||
@@ -37,6 +41,7 @@
|
||||
"@types/node": "^25.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@push.rocks/smartai": "^4.0.0",
|
||||
"@push.rocks/smartfs": "^1.5.1",
|
||||
"@push.rocks/smartrequest": "^5.0.1",
|
||||
@@ -57,6 +62,7 @@
|
||||
"ts/**/*",
|
||||
"ts_tools/**/*",
|
||||
"ts_compaction/**/*",
|
||||
"ts_mcp/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"assets/**/*",
|
||||
|
||||
Generated
+561
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,8 @@ 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
|
||||
- 💰 **Provider prompt caching** — uses SmartAI cache helpers for Anthropic breakpoints and OpenAI cache affinity
|
||||
- 📊 **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 +99,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 |
|
||||
@@ -165,6 +169,7 @@ const saved = result.toolCalls.some((call) =>
|
||||
|
||||
SmartAgent enables prompt-cache defaults by default:
|
||||
|
||||
- Cache behavior is provided by `@push.rocks/smartai`, so provider-specific cache metadata stays centralized there.
|
||||
- Anthropic-compatible models get cache breakpoints on the first two system messages and the two most recent non-system messages.
|
||||
- OpenAI models get `store: false` by default and, when `sessionId` is provided, `promptCacheKey: sessionId` with `promptCacheRetention: 'in_memory'`.
|
||||
- Longer retention is opt-in. Use `cache: { retention: '24h' }` for OpenAI or `cache: { retention: '1h' }` for Anthropic.
|
||||
@@ -239,6 +244,70 @@ await runAgent({
|
||||
});
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
MCP support lives in the `@push.rocks/smartagent/mcp` subpath. The main `@push.rocks/smartagent` import stays unchanged unless you opt in.
|
||||
|
||||
```typescript
|
||||
import { runAgent } from '@push.rocks/smartagent';
|
||||
import { createMcpTools } from '@push.rocks/smartagent/mcp';
|
||||
|
||||
const mcp = await createMcpTools({
|
||||
servers: {
|
||||
filesystem: {
|
||||
type: 'stdio',
|
||||
command: 'my-mcp-filesystem-server',
|
||||
args: ['/workspace/project'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runAgent({
|
||||
model,
|
||||
prompt: 'Use the available MCP tools to inspect the project.',
|
||||
tools: mcp.tools,
|
||||
maxSteps: 10,
|
||||
});
|
||||
|
||||
console.log(result.text);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
}
|
||||
```
|
||||
|
||||
`createMcpTools()` supports `stdio`, Streamable HTTP, custom transports, and pre-created MCP clients. When multiple servers are configured, exposed tool names are prefixed with the sanitized server name, for example `filesystem__read_file`. The returned `toolNameMap` maps exposed AI SDK tool names back to their original MCP server/tool names.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
createMcpTools,
|
||||
formatMcpToolResult,
|
||||
sanitizeMcpToolName,
|
||||
type IMcpClientLike,
|
||||
} from '../ts_mcp/index.js';
|
||||
|
||||
interface IFakeMcpClientState {
|
||||
calls: Array<{ name: string; arguments?: Record<string, unknown> }>;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
const createFakeMcpClient = (
|
||||
toolName: string,
|
||||
responsePrefix: string,
|
||||
state: IFakeMcpClientState,
|
||||
): IMcpClientLike => ({
|
||||
listTools: async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: toolName,
|
||||
description: `Runs ${toolName}`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['value'],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any),
|
||||
callTool: async (params) => {
|
||||
state.calls.push(params);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${responsePrefix}: ${String(params.arguments?.value ?? '')}`,
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
ok: true,
|
||||
},
|
||||
} as any;
|
||||
},
|
||||
close: async () => {
|
||||
state.closed = true;
|
||||
},
|
||||
});
|
||||
|
||||
tap.test('sanitizeMcpToolName should produce AI SDK-safe tool names', async () => {
|
||||
expect(sanitizeMcpToolName('read file/path')).toEqual('read_file_path');
|
||||
expect(sanitizeMcpToolName('123-start')).toEqual('_123-start');
|
||||
expect(sanitizeMcpToolName('***', 'fallback')).toEqual('fallback');
|
||||
});
|
||||
|
||||
tap.test('createMcpTools should expose prefixed tools for multiple MCP servers', async () => {
|
||||
const firstState: IFakeMcpClientState = { calls: [], closed: false };
|
||||
const secondState: IFakeMcpClientState = { calls: [], closed: false };
|
||||
|
||||
const mcpTools = await createMcpTools({
|
||||
servers: {
|
||||
'server one': {
|
||||
type: 'client',
|
||||
client: createFakeMcpClient('echo tool', 'first', firstState),
|
||||
},
|
||||
'server/two': {
|
||||
type: 'client',
|
||||
client: createFakeMcpClient('echo tool', 'second', secondState),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(Object.keys(mcpTools.tools).sort()).toEqual([
|
||||
'server_one__echo_tool',
|
||||
'server_two__echo_tool',
|
||||
]);
|
||||
expect(mcpTools.toolNameMap.server_one__echo_tool).toEqual({
|
||||
serverName: 'server one',
|
||||
toolName: 'echo tool',
|
||||
});
|
||||
|
||||
const output = await (mcpTools.tools.server_one__echo_tool as any).execute({ value: 'hello' });
|
||||
expect(output).toInclude('first: hello');
|
||||
expect(output).toInclude('Structured content');
|
||||
expect(firstState.calls).toEqual([
|
||||
{
|
||||
name: 'echo tool',
|
||||
arguments: { value: 'hello' },
|
||||
},
|
||||
]);
|
||||
|
||||
await mcpTools.close();
|
||||
expect(firstState.closed).toBeTrue();
|
||||
expect(secondState.closed).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('createMcpTools should support paginated tool discovery and custom formatting', async () => {
|
||||
const calls: Array<{ name: string; arguments?: Record<string, unknown> }> = [];
|
||||
const client: IMcpClientLike = {
|
||||
listTools: async (params) => ({
|
||||
tools: [
|
||||
{
|
||||
name: params?.cursor ? 'second tool' : 'first tool',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
nextCursor: params?.cursor ? undefined : 'next',
|
||||
} as any),
|
||||
callTool: async (params) => {
|
||||
calls.push(params);
|
||||
return { content: [{ type: 'text', text: 'ok' }] } as any;
|
||||
},
|
||||
};
|
||||
|
||||
const mcpTools = await createMcpTools({
|
||||
servers: {
|
||||
only: { type: 'client', client },
|
||||
},
|
||||
formatResult: (_result, context) => context.exposedToolName,
|
||||
});
|
||||
|
||||
expect(Object.keys(mcpTools.tools).sort()).toEqual(['first_tool', 'second_tool']);
|
||||
const output = await (mcpTools.tools.second_tool as any).execute('ignored');
|
||||
expect(output).toEqual('second_tool');
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
name: 'second tool',
|
||||
arguments: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
tap.test('formatMcpToolResult should handle MCP result shapes', async () => {
|
||||
expect(formatMcpToolResult({ toolResult: { ok: true } } as any)).toEqual({ ok: true });
|
||||
expect(formatMcpToolResult({ content: [{ type: 'text', text: 'failed' }], isError: true } as any)).toInclude(
|
||||
'MCP tool returned an error',
|
||||
);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
+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.3.0',
|
||||
version: '3.7.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 */
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import { Client, type ClientOptions } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import {
|
||||
StdioClientTransport,
|
||||
type StdioServerParameters,
|
||||
} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
type StreamableHTTPClientTransportOptions,
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { jsonSchema, tool } from '@push.rocks/smartai';
|
||||
import type { ToolSet } from '@push.rocks/smartai';
|
||||
|
||||
export type TMcpListToolsResult = Awaited<ReturnType<Client['listTools']>>;
|
||||
export type TMcpToolMetadata = TMcpListToolsResult['tools'][number];
|
||||
export type TMcpCallToolResult = Awaited<ReturnType<Client['callTool']>>;
|
||||
|
||||
export interface IMcpClientLike {
|
||||
listTools(params?: { cursor?: string }, options?: RequestOptions): Promise<TMcpListToolsResult>;
|
||||
callTool(
|
||||
params: { name: string; arguments?: Record<string, unknown> },
|
||||
resultSchema?: unknown,
|
||||
options?: RequestOptions,
|
||||
): Promise<TMcpCallToolResult>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMcpServerClientOptions {
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
clientOptions?: ClientOptions;
|
||||
requestOptions?: RequestOptions;
|
||||
}
|
||||
|
||||
export interface IMcpStdioServerConfig extends StdioServerParameters, IMcpServerClientOptions {
|
||||
type: 'stdio';
|
||||
}
|
||||
|
||||
export interface IMcpStreamableHttpServerConfig extends IMcpServerClientOptions {
|
||||
type: 'streamableHttp' | 'http';
|
||||
url: string | URL;
|
||||
transportOptions?: StreamableHTTPClientTransportOptions;
|
||||
}
|
||||
|
||||
export interface IMcpTransportServerConfig extends IMcpServerClientOptions {
|
||||
type: 'transport';
|
||||
transport: Transport;
|
||||
}
|
||||
|
||||
export interface IMcpClientServerConfig {
|
||||
type: 'client';
|
||||
client: IMcpClientLike;
|
||||
}
|
||||
|
||||
export type TMcpServerConfig =
|
||||
| IMcpStdioServerConfig
|
||||
| IMcpStreamableHttpServerConfig
|
||||
| IMcpTransportServerConfig
|
||||
| IMcpClientServerConfig;
|
||||
|
||||
export interface IMcpToolFormatContext {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
exposedToolName: string;
|
||||
}
|
||||
|
||||
export interface ICreateMcpToolsOptions {
|
||||
servers: Record<string, TMcpServerConfig>;
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
clientOptions?: ClientOptions;
|
||||
requestOptions?: RequestOptions;
|
||||
listToolsRequestOptions?: RequestOptions;
|
||||
callToolRequestOptions?: RequestOptions;
|
||||
prefixToolNames?: boolean;
|
||||
formatResult?: (result: TMcpCallToolResult, context: IMcpToolFormatContext) => unknown;
|
||||
}
|
||||
|
||||
export interface IMcpConnectedServer {
|
||||
name: string;
|
||||
client: IMcpClientLike;
|
||||
tools: TMcpToolMetadata[];
|
||||
}
|
||||
|
||||
export interface IMcpToolNameMapping {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
export interface ICreateMcpToolsResult {
|
||||
tools: ToolSet;
|
||||
servers: IMcpConnectedServer[];
|
||||
toolNameMap: Record<string, IMcpToolNameMapping>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const createMcpTools = async (
|
||||
options: ICreateMcpToolsOptions,
|
||||
): Promise<ICreateMcpToolsResult> => {
|
||||
const serverEntries = Object.entries(options.servers);
|
||||
if (serverEntries.length === 0) {
|
||||
throw new Error('createMcpTools requires at least one MCP server.');
|
||||
}
|
||||
|
||||
const connectedServers: IMcpConnectedServer[] = [];
|
||||
try {
|
||||
for (const [serverName, config] of serverEntries) {
|
||||
const client = await connectMcpServer(serverName, config, options);
|
||||
const tools = await listAllMcpTools(
|
||||
client,
|
||||
options.listToolsRequestOptions ?? getServerRequestOptions(config) ?? options.requestOptions,
|
||||
);
|
||||
connectedServers.push({ name: serverName, client, tools });
|
||||
}
|
||||
|
||||
const exposedTools: ToolSet = {};
|
||||
const toolNameMap: Record<string, IMcpToolNameMapping> = {};
|
||||
const usedToolNames = new Set<string>();
|
||||
const formatResult = options.formatResult ?? formatMcpToolResult;
|
||||
const shouldPrefixToolNames = options.prefixToolNames ?? connectedServers.length > 1;
|
||||
|
||||
for (const server of connectedServers) {
|
||||
for (const mcpTool of server.tools) {
|
||||
const baseToolName = createExposedToolName(server.name, mcpTool.name, shouldPrefixToolNames);
|
||||
const exposedToolName = createUniqueToolName(baseToolName, usedToolNames);
|
||||
usedToolNames.add(exposedToolName);
|
||||
toolNameMap[exposedToolName] = {
|
||||
serverName: server.name,
|
||||
toolName: mcpTool.name,
|
||||
};
|
||||
|
||||
exposedTools[exposedToolName] = tool({
|
||||
description: mcpTool.description ?? `MCP tool ${mcpTool.name} from ${server.name}`,
|
||||
inputSchema: jsonSchema(mcpTool.inputSchema as never),
|
||||
execute: async (input: unknown) => {
|
||||
const result = await server.client.callTool(
|
||||
{
|
||||
name: mcpTool.name,
|
||||
arguments: normalizeMcpArguments(input),
|
||||
},
|
||||
undefined,
|
||||
options.callToolRequestOptions ?? options.requestOptions,
|
||||
);
|
||||
return formatResult(result, {
|
||||
serverName: server.name,
|
||||
toolName: mcpTool.name,
|
||||
exposedToolName,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: exposedTools,
|
||||
servers: connectedServers,
|
||||
toolNameMap,
|
||||
close: async () => closeMcpClients(connectedServers),
|
||||
};
|
||||
} catch (error) {
|
||||
await closeMcpClients(connectedServers);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sanitizeMcpToolName = (name: string, fallback = 'mcp_tool'): string => {
|
||||
const sanitized = name
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
const safeName = sanitized || fallback;
|
||||
return /^[a-zA-Z_]/.test(safeName) ? safeName : `_${safeName}`;
|
||||
};
|
||||
|
||||
export const formatMcpToolResult = (result: TMcpCallToolResult): unknown => {
|
||||
const resultRecord = result as Record<string, unknown>;
|
||||
if (Object.prototype.hasOwnProperty.call(resultRecord, 'toolResult')) {
|
||||
return resultRecord.toolResult;
|
||||
}
|
||||
|
||||
const content = Array.isArray(resultRecord.content)
|
||||
? resultRecord.content.map(formatMcpContentPart).filter(Boolean).join('\n\n')
|
||||
: '';
|
||||
const formattedContent = resultRecord.isError && content
|
||||
? `MCP tool returned an error:\n${content}`
|
||||
: content;
|
||||
const structuredContent = resultRecord.structuredContent;
|
||||
|
||||
if (structuredContent !== undefined && formattedContent) {
|
||||
return `${formattedContent}\n\nStructured content:\n${stringifyJson(structuredContent)}`;
|
||||
}
|
||||
if (structuredContent !== undefined) {
|
||||
return structuredContent;
|
||||
}
|
||||
if (formattedContent) {
|
||||
return formattedContent;
|
||||
}
|
||||
return stringifyJson(result);
|
||||
};
|
||||
|
||||
const connectMcpServer = async (
|
||||
serverName: string,
|
||||
config: TMcpServerConfig,
|
||||
options: ICreateMcpToolsOptions,
|
||||
): Promise<IMcpClientLike> => {
|
||||
if (config.type === 'client') {
|
||||
return config.client;
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: config.clientName ?? options.clientName ?? 'smartagent-mcp',
|
||||
version: config.clientVersion ?? options.clientVersion ?? '1.0.0',
|
||||
},
|
||||
config.clientOptions ?? options.clientOptions ?? { capabilities: {} },
|
||||
);
|
||||
const transport = createMcpTransport(config);
|
||||
await client.connect(transport, config.requestOptions ?? options.requestOptions);
|
||||
return client;
|
||||
};
|
||||
|
||||
const createMcpTransport = (
|
||||
config: Exclude<TMcpServerConfig, IMcpClientServerConfig>,
|
||||
): Transport => {
|
||||
if (config.type === 'stdio') {
|
||||
const { type, clientName, clientVersion, clientOptions, requestOptions, ...serverParameters } = config;
|
||||
return new StdioClientTransport(serverParameters);
|
||||
}
|
||||
|
||||
if (config.type === 'streamableHttp' || config.type === 'http') {
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), config.transportOptions);
|
||||
}
|
||||
|
||||
if (config.type === 'transport') {
|
||||
return config.transport;
|
||||
}
|
||||
|
||||
throw new Error('Unsupported MCP server transport.');
|
||||
};
|
||||
|
||||
const getServerRequestOptions = (config: TMcpServerConfig): RequestOptions | undefined => {
|
||||
return config.type === 'client' ? undefined : config.requestOptions;
|
||||
};
|
||||
|
||||
const listAllMcpTools = async (
|
||||
client: IMcpClientLike,
|
||||
requestOptions?: RequestOptions,
|
||||
): Promise<TMcpToolMetadata[]> => {
|
||||
const tools: TMcpToolMetadata[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const result = await client.listTools(cursor ? { cursor } : undefined, requestOptions);
|
||||
tools.push(...result.tools);
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor);
|
||||
return tools;
|
||||
};
|
||||
|
||||
const createExposedToolName = (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
prefixToolName: boolean,
|
||||
): string => {
|
||||
const safeToolName = sanitizeMcpToolName(toolName);
|
||||
if (!prefixToolName) {
|
||||
return safeToolName;
|
||||
}
|
||||
return `${sanitizeMcpToolName(serverName, 'mcp')}__${safeToolName}`;
|
||||
};
|
||||
|
||||
const createUniqueToolName = (baseToolName: string, usedToolNames: Set<string>): string => {
|
||||
if (!usedToolNames.has(baseToolName)) {
|
||||
return baseToolName;
|
||||
}
|
||||
let suffix = 2;
|
||||
while (usedToolNames.has(`${baseToolName}_${suffix}`)) {
|
||||
suffix++;
|
||||
}
|
||||
return `${baseToolName}_${suffix}`;
|
||||
};
|
||||
|
||||
const normalizeMcpArguments = (input: unknown): Record<string, unknown> => {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return {};
|
||||
}
|
||||
return input as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const formatMcpContentPart = (part: unknown): string => {
|
||||
if (!part || typeof part !== 'object') {
|
||||
return stringifyJson(part);
|
||||
}
|
||||
const partRecord = part as Record<string, unknown>;
|
||||
const type = partRecord.type;
|
||||
if (type === 'text') {
|
||||
return typeof partRecord.text === 'string' ? partRecord.text : '';
|
||||
}
|
||||
if (type === 'image' || type === 'audio') {
|
||||
const data = typeof partRecord.data === 'string' ? partRecord.data : '';
|
||||
const mimeType = typeof partRecord.mimeType === 'string' ? partRecord.mimeType : 'application/octet-stream';
|
||||
return `[${type}: ${mimeType}; base64 length ${data.length}]`;
|
||||
}
|
||||
if (type === 'resource') {
|
||||
return formatMcpResourcePart(partRecord.resource);
|
||||
}
|
||||
if (type === 'resource_link') {
|
||||
const name = typeof partRecord.name === 'string' ? partRecord.name : 'resource';
|
||||
const uri = typeof partRecord.uri === 'string' ? partRecord.uri : '';
|
||||
return `Resource link ${name}${uri ? `: ${uri}` : ''}`;
|
||||
}
|
||||
return stringifyJson(part);
|
||||
};
|
||||
|
||||
const formatMcpResourcePart = (resource: unknown): string => {
|
||||
if (!resource || typeof resource !== 'object') {
|
||||
return stringifyJson(resource);
|
||||
}
|
||||
const resourceRecord = resource as Record<string, unknown>;
|
||||
const uri = typeof resourceRecord.uri === 'string' ? resourceRecord.uri : 'resource';
|
||||
if (typeof resourceRecord.text === 'string') {
|
||||
return `Resource ${uri}:\n${resourceRecord.text}`;
|
||||
}
|
||||
if (typeof resourceRecord.blob === 'string') {
|
||||
const mimeType = typeof resourceRecord.mimeType === 'string' ? resourceRecord.mimeType : 'application/octet-stream';
|
||||
return `[resource ${uri}: ${mimeType}; base64 length ${resourceRecord.blob.length}]`;
|
||||
}
|
||||
return stringifyJson(resource);
|
||||
};
|
||||
|
||||
const stringifyJson = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, undefined, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const closeMcpClients = async (servers: IMcpConnectedServer[]): Promise<void> => {
|
||||
await Promise.all(servers.map((server) => server.client.close?.()));
|
||||
};
|
||||
+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