@aragon-agent/core 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/engine/agent-loop.d.ts +52 -0
- package/dist/engine/agent-loop.js +260 -0
- package/dist/engine/agent.d.ts +129 -0
- package/dist/engine/agent.js +283 -0
- package/dist/engine/message-manager.d.ts +26 -0
- package/dist/engine/message-manager.js +39 -0
- package/dist/engine/steering.d.ts +27 -0
- package/dist/engine/steering.js +51 -0
- package/dist/engine/watchdog.d.ts +24 -0
- package/dist/engine/watchdog.js +50 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +63 -0
- package/dist/llm/model-registry.d.ts +51 -0
- package/dist/llm/model-registry.js +277 -0
- package/dist/llm/provider.d.ts +72 -0
- package/dist/llm/provider.js +77 -0
- package/dist/llm/providers/anthropic.d.ts +23 -0
- package/dist/llm/providers/anthropic.js +420 -0
- package/dist/llm/providers/google.d.ts +22 -0
- package/dist/llm/providers/google.js +336 -0
- package/dist/llm/providers/index.d.ts +67 -0
- package/dist/llm/providers/index.js +127 -0
- package/dist/llm/providers/openai.d.ts +22 -0
- package/dist/llm/providers/openai.js +353 -0
- package/dist/llm/stream-utils.d.ts +33 -0
- package/dist/llm/stream-utils.js +132 -0
- package/dist/llm/types.d.ts +129 -0
- package/dist/llm/types.js +17 -0
- package/dist/sandbox/console-capture.d.ts +29 -0
- package/dist/sandbox/console-capture.js +56 -0
- package/dist/sandbox/http-bridge.d.ts +47 -0
- package/dist/sandbox/http-bridge.js +143 -0
- package/dist/sandbox/isolated-vm-sandbox.d.ts +50 -0
- package/dist/sandbox/isolated-vm-sandbox.js +226 -0
- package/dist/sandbox/tool-bridge.d.ts +42 -0
- package/dist/sandbox/tool-bridge.js +98 -0
- package/dist/sandbox/types.d.ts +84 -0
- package/dist/sandbox/types.js +8 -0
- package/dist/skills/constants.d.ts +122 -0
- package/dist/skills/constants.js +179 -0
- package/dist/skills/disclosure.d.ts +146 -0
- package/dist/skills/disclosure.js +788 -0
- package/dist/skills/frontmatter.d.ts +27 -0
- package/dist/skills/frontmatter.js +177 -0
- package/dist/skills/index.d.ts +21 -0
- package/dist/skills/index.js +18 -0
- package/dist/skills/skill-find-tool.d.ts +41 -0
- package/dist/skills/skill-find-tool.js +117 -0
- package/dist/skills/skill-registry.d.ts +96 -0
- package/dist/skills/skill-registry.js +208 -0
- package/dist/skills/skill-tool.d.ts +44 -0
- package/dist/skills/skill-tool.js +96 -0
- package/dist/skills/tool-policy.d.ts +91 -0
- package/dist/skills/tool-policy.js +272 -0
- package/dist/skills/types.d.ts +271 -0
- package/dist/skills/types.js +8 -0
- package/dist/skills/validate.d.ts +37 -0
- package/dist/skills/validate.js +264 -0
- package/dist/tools/executor.d.ts +54 -0
- package/dist/tools/executor.js +206 -0
- package/dist/tools/helpers.d.ts +69 -0
- package/dist/tools/helpers.js +87 -0
- package/dist/tools/registry.d.ts +58 -0
- package/dist/tools/registry.js +119 -0
- package/dist/tools/types.d.ts +140 -0
- package/dist/tools/types.js +7 -0
- package/dist/tools/validator.d.ts +46 -0
- package/dist/tools/validator.js +210 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +7 -0
- package/package.json +115 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill validation — frontmatter contract (§4.4) and staged-install safety (§8.3).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: no filesystem, no process. The staged validator takes a
|
|
5
|
+
* pre-listed file table so the same rules run identically over a real staging
|
|
6
|
+
* directory (CLI) and over a fixture (tests).
|
|
7
|
+
*/
|
|
8
|
+
import { SKILL_ALLOWED_TOOLS_MAX, SKILL_DESC_MAX, SKILL_KEYWORDS_MAX, SKILL_KEYWORD_LEN_MAX, SKILL_META_MAX, SKILL_NAME_MAX, SKILL_NAME_PATTERN, STAGED_MAX_DEPTH, STAGED_MAX_FILE_BYTES, STAGED_MAX_FILES, STAGED_MAX_TOTAL_BYTES, WINDOWS_RESERVED_NAMES, } from './constants.js';
|
|
9
|
+
const ACTIVATIONS = ['auto', 'always', 'manual'];
|
|
10
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:[-+].+)?$/;
|
|
11
|
+
/**
|
|
12
|
+
* Characters that let a description escape the `<available_skills>` block.
|
|
13
|
+
*
|
|
14
|
+
* WARN, NOT ERROR — and that asymmetry is load-bearing (§4.4). Community skills
|
|
15
|
+
* under `.claude/skills` legitimately write `<div>` in a description; failing
|
|
16
|
+
* them would break interop (D8). Escaping is enforced UNCONDITIONALLY at every
|
|
17
|
+
* render point by `sanitizeForPromptBlock()`; validation only tells the user.
|
|
18
|
+
* Never let "validation passed" become a licence to skip sanitizing.
|
|
19
|
+
*/
|
|
20
|
+
function isControlCode(code) {
|
|
21
|
+
return code < 0x20 || code === 0x7f;
|
|
22
|
+
}
|
|
23
|
+
/** True when `s` holds a C0/DEL control character. */
|
|
24
|
+
export function hasControlChar(s) {
|
|
25
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
26
|
+
if (isControlCode(s.charCodeAt(i)))
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
/** True when `s` holds an angle bracket or a control character. */
|
|
32
|
+
function hasAngleOrControl(s) {
|
|
33
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
34
|
+
const code = s.charCodeAt(i);
|
|
35
|
+
if (code === 0x3c || code === 0x3e || isControlCode(code))
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function asString(v) {
|
|
41
|
+
if (typeof v === 'string')
|
|
42
|
+
return v;
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
function asArray(v) {
|
|
46
|
+
if (Array.isArray(v))
|
|
47
|
+
return v;
|
|
48
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
49
|
+
return [v.trim()];
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate a parsed frontmatter map against the §4.2 contract.
|
|
54
|
+
*
|
|
55
|
+
* Error codes are a stable public surface — the UI and the tests both key off
|
|
56
|
+
* them, so renaming one is a breaking change.
|
|
57
|
+
*/
|
|
58
|
+
export function validateSkillFrontmatter(data, dirName) {
|
|
59
|
+
const issues = [];
|
|
60
|
+
const err = (code, message) => {
|
|
61
|
+
issues.push({ level: 'error', code, message });
|
|
62
|
+
};
|
|
63
|
+
const warn = (code, message) => {
|
|
64
|
+
issues.push({ level: 'warn', code, message });
|
|
65
|
+
};
|
|
66
|
+
const name = asString(data.name)?.trim();
|
|
67
|
+
if (!name) {
|
|
68
|
+
err('SKILL_NAME_MISSING', 'name is required');
|
|
69
|
+
}
|
|
70
|
+
else if (!SKILL_NAME_PATTERN.test(name) || name.length > SKILL_NAME_MAX) {
|
|
71
|
+
err('SKILL_NAME_INVALID', `name "${name}" must be kebab-case ([a-z0-9-], no leading/trailing/double dash) and at most ${SKILL_NAME_MAX} characters`);
|
|
72
|
+
}
|
|
73
|
+
else if (name !== dirName) {
|
|
74
|
+
warn('SKILL_NAME_DIR_MISMATCH', `frontmatter name "${name}" does not match directory "${dirName}"`);
|
|
75
|
+
}
|
|
76
|
+
const description = asString(data.description)?.trim();
|
|
77
|
+
if (!description) {
|
|
78
|
+
err('SKILL_DESC_MISSING', 'description is required');
|
|
79
|
+
}
|
|
80
|
+
else if (description.length > SKILL_DESC_MAX) {
|
|
81
|
+
err('SKILL_DESC_TOO_LONG', `description is ${description.length} characters (max ${SKILL_DESC_MAX})`);
|
|
82
|
+
}
|
|
83
|
+
else if (hasAngleOrControl(description)) {
|
|
84
|
+
warn('SKILL_DESC_UNSAFE_CHARS', 'description contains angle brackets or control characters; they will be neutralized before reaching the model');
|
|
85
|
+
}
|
|
86
|
+
const version = asString(data.version)?.trim();
|
|
87
|
+
if (version && !SEMVER.test(version)) {
|
|
88
|
+
warn('SKILL_VERSION_INVALID', `version "${version}" is not semver; falling back to 0.0.0`);
|
|
89
|
+
}
|
|
90
|
+
const activation = asString(data.activation)?.trim();
|
|
91
|
+
if (activation && !ACTIVATIONS.includes(activation)) {
|
|
92
|
+
warn('SKILL_ACTIVATION_INVALID', `unknown activation "${activation}"; falling back to auto`);
|
|
93
|
+
}
|
|
94
|
+
const allowedTools = data['allowed-tools'] ?? data.allowedTools;
|
|
95
|
+
if (allowedTools !== undefined && !Array.isArray(allowedTools) && typeof allowedTools !== 'string') {
|
|
96
|
+
warn('SKILL_ALLOWED_TOOLS_INVALID', 'allowed-tools must be a list of tool names');
|
|
97
|
+
}
|
|
98
|
+
else if (Array.isArray(allowedTools) && allowedTools.length > SKILL_ALLOWED_TOOLS_MAX) {
|
|
99
|
+
warn('SKILL_ALLOWED_TOOLS_INVALID', `allowed-tools has more than ${SKILL_ALLOWED_TOOLS_MAX} entries`);
|
|
100
|
+
}
|
|
101
|
+
return issues;
|
|
102
|
+
}
|
|
103
|
+
/** Build the normalized frontmatter view, applying every documented default. */
|
|
104
|
+
export function normalizeFrontmatter(data, fallbackName) {
|
|
105
|
+
const activationRaw = asString(data.activation)?.trim();
|
|
106
|
+
const activation = ACTIVATIONS.includes(activationRaw ?? '')
|
|
107
|
+
? activationRaw
|
|
108
|
+
: 'auto';
|
|
109
|
+
const versionRaw = asString(data.version)?.trim();
|
|
110
|
+
const version = versionRaw && SEMVER.test(versionRaw) ? versionRaw : '0.0.0';
|
|
111
|
+
const keywords = (asArray(data.keywords) ?? [])
|
|
112
|
+
.slice(0, SKILL_KEYWORDS_MAX)
|
|
113
|
+
.map((k) => k.slice(0, SKILL_KEYWORD_LEN_MAX));
|
|
114
|
+
const allowedTools = (asArray(data['allowed-tools'] ?? data.allowedTools) ?? []).slice(0, SKILL_ALLOWED_TOOLS_MAX);
|
|
115
|
+
const meta = (key) => {
|
|
116
|
+
const v = asString(data[key])?.trim();
|
|
117
|
+
return v && v.length > 0 ? v.slice(0, SKILL_META_MAX) : undefined;
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
name: asString(data.name)?.trim() || fallbackName,
|
|
121
|
+
description: asString(data.description)?.trim() ?? '',
|
|
122
|
+
version,
|
|
123
|
+
...(meta('license') ? { license: meta('license') } : {}),
|
|
124
|
+
...(meta('author') ? { author: meta('author') } : {}),
|
|
125
|
+
...(meta('homepage') ? { homepage: meta('homepage') } : {}),
|
|
126
|
+
keywords,
|
|
127
|
+
allowedTools,
|
|
128
|
+
activation,
|
|
129
|
+
raw: data,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Staged install validation (§8.3 step 2)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* Reject a relative path that could escape the staging root or is unusable on
|
|
137
|
+
* Windows. This is the SECOND line of defence — the archive extractor applies
|
|
138
|
+
* the same rules while writing (§9.2.1). Both are required: the extractor
|
|
139
|
+
* guards what lands on disk, this guards what we then promote into the skills
|
|
140
|
+
* root (a plain local-directory source never goes through the extractor).
|
|
141
|
+
*/
|
|
142
|
+
export function checkStagedPath(path) {
|
|
143
|
+
const normalized = path.replace(/\\/g, '/');
|
|
144
|
+
if (normalized.length === 0) {
|
|
145
|
+
return { level: 'error', code: 'SKILL_PATH_EMPTY', message: 'empty entry path' };
|
|
146
|
+
}
|
|
147
|
+
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith('/')) {
|
|
148
|
+
return {
|
|
149
|
+
level: 'error',
|
|
150
|
+
code: 'SKILL_PATH_ABSOLUTE',
|
|
151
|
+
message: `absolute path not allowed: ${path}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const segments = normalized.split('/').filter((s) => s.length > 0);
|
|
155
|
+
if (segments.some((s) => s === '..')) {
|
|
156
|
+
return {
|
|
157
|
+
level: 'error',
|
|
158
|
+
code: 'SKILL_PATH_TRAVERSAL',
|
|
159
|
+
message: `path escapes the skill directory: ${path}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (hasControlChar(normalized)) {
|
|
163
|
+
return {
|
|
164
|
+
level: 'error',
|
|
165
|
+
code: 'SKILL_PATH_CONTROL_CHARS',
|
|
166
|
+
message: `control characters in path: ${JSON.stringify(path)}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
for (const segment of segments) {
|
|
170
|
+
const base = (segment.split('.')[0] ?? '').toLowerCase();
|
|
171
|
+
if (WINDOWS_RESERVED_NAMES.has(base)) {
|
|
172
|
+
return {
|
|
173
|
+
level: 'error',
|
|
174
|
+
code: 'SKILL_PATH_RESERVED_NAME',
|
|
175
|
+
message: `Windows reserved name in path: ${path}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (segments.length > STAGED_MAX_DEPTH) {
|
|
180
|
+
return {
|
|
181
|
+
level: 'error',
|
|
182
|
+
code: 'SKILL_PATH_TOO_DEEP',
|
|
183
|
+
message: `path nests deeper than ${STAGED_MAX_DEPTH} levels: ${path}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Validate a staged skill before it is promoted into a skills root.
|
|
190
|
+
*
|
|
191
|
+
* `entryText` is the raw SKILL.md; `files` is the full staged file table
|
|
192
|
+
* (SKILL.md included). Returns `ok: false` if ANY error-level issue is found —
|
|
193
|
+
* the installer must then abort and leave the target untouched.
|
|
194
|
+
*/
|
|
195
|
+
export function validateStagedSkill(entryText, files, dirName, parse) {
|
|
196
|
+
const issues = [];
|
|
197
|
+
let totalBytes = 0;
|
|
198
|
+
if (entryText === null) {
|
|
199
|
+
issues.push({
|
|
200
|
+
level: 'error',
|
|
201
|
+
code: 'SKILL_MD_MISSING',
|
|
202
|
+
message: 'SKILL.md not found in the source',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (files.length > STAGED_MAX_FILES) {
|
|
206
|
+
issues.push({
|
|
207
|
+
level: 'error',
|
|
208
|
+
code: 'SKILL_TOO_MANY_FILES',
|
|
209
|
+
message: `${files.length} files exceeds the limit of ${STAGED_MAX_FILES}`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
for (const file of files) {
|
|
213
|
+
if (file.isSymlink) {
|
|
214
|
+
issues.push({
|
|
215
|
+
level: 'error',
|
|
216
|
+
code: 'SKILL_SYMLINK_REJECTED',
|
|
217
|
+
message: `symbolic links are not allowed: ${file.path}`,
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const pathIssue = checkStagedPath(file.path);
|
|
222
|
+
if (pathIssue) {
|
|
223
|
+
issues.push(pathIssue);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (file.bytes > STAGED_MAX_FILE_BYTES) {
|
|
227
|
+
issues.push({
|
|
228
|
+
level: 'error',
|
|
229
|
+
code: 'SKILL_FILE_TOO_LARGE',
|
|
230
|
+
message: `${file.path} is ${file.bytes} bytes (max ${STAGED_MAX_FILE_BYTES})`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
totalBytes += file.bytes;
|
|
234
|
+
}
|
|
235
|
+
if (totalBytes > STAGED_MAX_TOTAL_BYTES) {
|
|
236
|
+
issues.push({
|
|
237
|
+
level: 'error',
|
|
238
|
+
code: 'SKILL_TOTAL_TOO_LARGE',
|
|
239
|
+
message: `total size ${totalBytes} bytes exceeds ${STAGED_MAX_TOTAL_BYTES}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
let frontmatter = null;
|
|
243
|
+
if (entryText !== null) {
|
|
244
|
+
const parsed = parse(entryText);
|
|
245
|
+
if (!parsed) {
|
|
246
|
+
issues.push({
|
|
247
|
+
level: 'error',
|
|
248
|
+
code: 'SKILL_FRONTMATTER_MISSING',
|
|
249
|
+
message: 'SKILL.md has no parseable YAML frontmatter block',
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
issues.push(...validateSkillFrontmatter(parsed.data, dirName));
|
|
254
|
+
frontmatter = normalizeFrontmatter(parsed.data, dirName);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
ok: !issues.some((i) => i.level === 'error'),
|
|
259
|
+
issues,
|
|
260
|
+
frontmatter,
|
|
261
|
+
fileCount: files.length,
|
|
262
|
+
totalBytes,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool System — Tool Executor
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the full lifecycle of a tool invocation:
|
|
5
|
+
* 1. Tool lookup from registry
|
|
6
|
+
* 2. Parameter validation against JSON Schema
|
|
7
|
+
* 3. Timeout wrapping via AbortController
|
|
8
|
+
* 4. Execution with merged AbortSignal
|
|
9
|
+
* 5. Output truncation
|
|
10
|
+
* 6. Error capture into ToolResult
|
|
11
|
+
*/
|
|
12
|
+
import type { ToolRegistry } from './registry.js';
|
|
13
|
+
import type { ToolExecutionResult, ToolProgressUpdate } from './types.js';
|
|
14
|
+
/** Options for configuring the ToolExecutor. */
|
|
15
|
+
export interface ToolExecutorOptions {
|
|
16
|
+
/** Default per-tool execution timeout in milliseconds. Defaults to 120_000 (2 minutes). */
|
|
17
|
+
defaultTimeout?: number;
|
|
18
|
+
/** Per-tool timeout overrides, keyed by tool name. */
|
|
19
|
+
timeoutOverrides?: Record<string, number>;
|
|
20
|
+
/** Maximum size (in bytes) of combined text output before truncation. Defaults to 100_000 (100 KB). */
|
|
21
|
+
maxOutputSize?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Executes tool calls with validation, timeout management,
|
|
25
|
+
* output truncation, and comprehensive error handling.
|
|
26
|
+
*/
|
|
27
|
+
export declare class ToolExecutor {
|
|
28
|
+
private readonly registry;
|
|
29
|
+
private readonly validator;
|
|
30
|
+
private readonly defaultTimeout;
|
|
31
|
+
private readonly timeoutOverrides;
|
|
32
|
+
private readonly maxOutputSize;
|
|
33
|
+
constructor(registry: ToolRegistry, options?: ToolExecutorOptions);
|
|
34
|
+
/**
|
|
35
|
+
* Execute a tool call end-to-end.
|
|
36
|
+
*
|
|
37
|
+
* Never throws — all errors are captured as error ToolResults.
|
|
38
|
+
*
|
|
39
|
+
* @param toolCallId - The LLM-generated tool call ID.
|
|
40
|
+
* @param toolName - Name of the tool to execute.
|
|
41
|
+
* @param rawArgs - Raw parameter object from the LLM.
|
|
42
|
+
* @param signal - Optional external AbortSignal for cancellation.
|
|
43
|
+
* @param onProgress - Optional progress callback forwarded to the tool.
|
|
44
|
+
*/
|
|
45
|
+
execute(toolCallId: string, toolName: string, rawArgs: Record<string, unknown>, signal?: AbortSignal, onProgress?: (update: ToolProgressUpdate) => void): Promise<ToolExecutionResult>;
|
|
46
|
+
/**
|
|
47
|
+
* Truncate text content blocks that exceed `maxOutputSize` bytes.
|
|
48
|
+
*/
|
|
49
|
+
private truncateResult;
|
|
50
|
+
/**
|
|
51
|
+
* Build a ToolExecutionResult with computed duration and error flag.
|
|
52
|
+
*/
|
|
53
|
+
private buildResult;
|
|
54
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool System — Tool Executor
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates the full lifecycle of a tool invocation:
|
|
5
|
+
* 1. Tool lookup from registry
|
|
6
|
+
* 2. Parameter validation against JSON Schema
|
|
7
|
+
* 3. Timeout wrapping via AbortController
|
|
8
|
+
* 4. Execution with merged AbortSignal
|
|
9
|
+
* 5. Output truncation
|
|
10
|
+
* 6. Error capture into ToolResult
|
|
11
|
+
*/
|
|
12
|
+
import { ToolParamValidator, ToolValidationError } from './validator.js';
|
|
13
|
+
import { errorResult } from './helpers.js';
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Constants
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
18
|
+
const DEFAULT_MAX_OUTPUT_SIZE = 100_000;
|
|
19
|
+
const TRUNCATION_SUFFIX = '\n... [truncated]';
|
|
20
|
+
/** Sentinel symbol used to identify timeout aborts vs external aborts. */
|
|
21
|
+
const TIMEOUT_REASON = Symbol('tool-timeout');
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// ToolExecutor
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Executes tool calls with validation, timeout management,
|
|
27
|
+
* output truncation, and comprehensive error handling.
|
|
28
|
+
*/
|
|
29
|
+
export class ToolExecutor {
|
|
30
|
+
registry;
|
|
31
|
+
validator = new ToolParamValidator();
|
|
32
|
+
defaultTimeout;
|
|
33
|
+
timeoutOverrides;
|
|
34
|
+
maxOutputSize;
|
|
35
|
+
constructor(registry, options = {}) {
|
|
36
|
+
this.registry = registry;
|
|
37
|
+
this.defaultTimeout = options.defaultTimeout ?? DEFAULT_TIMEOUT_MS;
|
|
38
|
+
this.timeoutOverrides = options.timeoutOverrides ?? {};
|
|
39
|
+
this.maxOutputSize = options.maxOutputSize ?? DEFAULT_MAX_OUTPUT_SIZE;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Execute a tool call end-to-end.
|
|
43
|
+
*
|
|
44
|
+
* Never throws — all errors are captured as error ToolResults.
|
|
45
|
+
*
|
|
46
|
+
* @param toolCallId - The LLM-generated tool call ID.
|
|
47
|
+
* @param toolName - Name of the tool to execute.
|
|
48
|
+
* @param rawArgs - Raw parameter object from the LLM.
|
|
49
|
+
* @param signal - Optional external AbortSignal for cancellation.
|
|
50
|
+
* @param onProgress - Optional progress callback forwarded to the tool.
|
|
51
|
+
*/
|
|
52
|
+
async execute(toolCallId, toolName, rawArgs, signal, onProgress) {
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
// 1. Look up tool in registry.
|
|
55
|
+
const tool = this.registry.get(toolName);
|
|
56
|
+
if (!tool) {
|
|
57
|
+
return this.buildResult(toolCallId, toolName, errorResult(`Tool "${toolName}" not found`), startTime);
|
|
58
|
+
}
|
|
59
|
+
// 2. Detect malformed JSON from the LLM provider (streaming truncation).
|
|
60
|
+
if (rawArgs.__parse_error) {
|
|
61
|
+
return this.buildResult(toolCallId, toolName, errorResult('Tool call failed: the LLM returned incomplete JSON for the parameters ' +
|
|
62
|
+
'(likely hit the max_tokens limit mid-generation). ' +
|
|
63
|
+
'Please retry by writing the content in smaller pieces or reducing output size.'), startTime);
|
|
64
|
+
}
|
|
65
|
+
// 3. Validate parameters.
|
|
66
|
+
let validatedParams;
|
|
67
|
+
try {
|
|
68
|
+
validatedParams = await this.validator.validate(tool.parameters, rawArgs);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err instanceof ToolValidationError) {
|
|
72
|
+
return this.buildResult(toolCallId, toolName, errorResult(`Invalid parameters: ${err.message}`), startTime);
|
|
73
|
+
}
|
|
74
|
+
return this.buildResult(toolCallId, toolName, errorResult(`Parameter validation error: ${err instanceof Error ? err.message : String(err)}`), startTime);
|
|
75
|
+
}
|
|
76
|
+
// 4. Set up timeout and merged abort signal.
|
|
77
|
+
const timeoutMs = this.timeoutOverrides[toolName] ?? this.defaultTimeout;
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timeoutId = setTimeout(() => controller.abort(TIMEOUT_REASON), timeoutMs);
|
|
80
|
+
// Propagate external signal to our controller.
|
|
81
|
+
const externalAbortHandler = () => {
|
|
82
|
+
controller.abort(signal?.reason ?? new Error('aborted'));
|
|
83
|
+
};
|
|
84
|
+
if (signal) {
|
|
85
|
+
if (signal.aborted) {
|
|
86
|
+
clearTimeout(timeoutId);
|
|
87
|
+
return this.buildResult(toolCallId, toolName, errorResult('Execution aborted before start'), startTime);
|
|
88
|
+
}
|
|
89
|
+
signal.addEventListener('abort', externalAbortHandler, { once: true });
|
|
90
|
+
}
|
|
91
|
+
// 5. Execute the tool.
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
const context = {
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
onProgress,
|
|
97
|
+
};
|
|
98
|
+
result = await tool.execute(toolCallId, validatedParams, context);
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (isAbortError(err) || controller.signal.aborted) {
|
|
102
|
+
const isTimeout = controller.signal.reason === TIMEOUT_REASON;
|
|
103
|
+
result = errorResult(isTimeout
|
|
104
|
+
? `Tool "${toolName}" timed out after ${timeoutMs}ms`
|
|
105
|
+
: `Tool "${toolName}" was aborted`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
result = errorResult(`Tool "${toolName}" threw an error: ${message}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
clearTimeout(timeoutId);
|
|
114
|
+
if (signal) {
|
|
115
|
+
signal.removeEventListener('abort', externalAbortHandler);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// 6. Truncate output if necessary.
|
|
119
|
+
result = this.truncateResult(result);
|
|
120
|
+
return this.buildResult(toolCallId, toolName, result, startTime);
|
|
121
|
+
}
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
// Internal Helpers
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* Truncate text content blocks that exceed `maxOutputSize` bytes.
|
|
127
|
+
*/
|
|
128
|
+
truncateResult(result) {
|
|
129
|
+
let totalSize = 0;
|
|
130
|
+
for (const block of result.content) {
|
|
131
|
+
if (block.type === 'text') {
|
|
132
|
+
totalSize += Buffer.byteLength(block.text, 'utf-8');
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (totalSize <= this.maxOutputSize) {
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
// Rebuild content with truncated text blocks.
|
|
139
|
+
let remainingBudget = this.maxOutputSize;
|
|
140
|
+
const truncatedContent = result.content.map((block) => {
|
|
141
|
+
if (block.type !== 'text')
|
|
142
|
+
return block;
|
|
143
|
+
const blockSize = Buffer.byteLength(block.text, 'utf-8');
|
|
144
|
+
if (blockSize <= remainingBudget) {
|
|
145
|
+
remainingBudget -= blockSize;
|
|
146
|
+
return block;
|
|
147
|
+
}
|
|
148
|
+
// Truncate this block.
|
|
149
|
+
if (remainingBudget <= 0) {
|
|
150
|
+
return { type: 'text', text: TRUNCATION_SUFFIX };
|
|
151
|
+
}
|
|
152
|
+
// Find a safe character boundary to cut at.
|
|
153
|
+
const truncated = truncateUtf8(block.text, remainingBudget);
|
|
154
|
+
remainingBudget = 0;
|
|
155
|
+
return { type: 'text', text: truncated + TRUNCATION_SUFFIX };
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
content: truncatedContent,
|
|
159
|
+
isError: result.isError,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Build a ToolExecutionResult with computed duration and error flag.
|
|
164
|
+
*/
|
|
165
|
+
buildResult(toolCallId, toolName, result, startTime) {
|
|
166
|
+
return {
|
|
167
|
+
result,
|
|
168
|
+
duration: Date.now() - startTime,
|
|
169
|
+
isError: result.isError === true,
|
|
170
|
+
toolName,
|
|
171
|
+
toolCallId,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Utility Functions
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Check whether an error is an AbortError (from AbortController.abort()).
|
|
180
|
+
*/
|
|
181
|
+
function isAbortError(err) {
|
|
182
|
+
if (err instanceof DOMException && err.name === 'AbortError')
|
|
183
|
+
return true;
|
|
184
|
+
if (err instanceof Error && err.name === 'AbortError')
|
|
185
|
+
return true;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Truncate a UTF-8 string to fit within `maxBytes`, respecting
|
|
190
|
+
* character boundaries.
|
|
191
|
+
*/
|
|
192
|
+
function truncateUtf8(text, maxBytes) {
|
|
193
|
+
const buf = Buffer.from(text, 'utf-8');
|
|
194
|
+
if (buf.length <= maxBytes)
|
|
195
|
+
return text;
|
|
196
|
+
// Slice the buffer and decode — Node handles incomplete multibyte
|
|
197
|
+
// sequences by replacing them, which is acceptable here.
|
|
198
|
+
const sliced = buf.subarray(0, maxBytes);
|
|
199
|
+
// Decode, stripping any replacement character at the end.
|
|
200
|
+
let result = sliced.toString('utf-8');
|
|
201
|
+
// Remove trailing replacement character if the slice broke a multibyte char.
|
|
202
|
+
if (result.endsWith('\uFFFD')) {
|
|
203
|
+
result = result.slice(0, -1);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool System — Convenience Helpers
|
|
3
|
+
*
|
|
4
|
+
* Provides factory functions for creating ToolResult values and
|
|
5
|
+
* a builder function for defining tools with less boilerplate.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentTool, JSONSchema7, ToolExecuteFn, ToolResult, ToolResultContent } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Create a successful text result.
|
|
10
|
+
*
|
|
11
|
+
* @param text - The text content to return.
|
|
12
|
+
*/
|
|
13
|
+
export declare function textResult(text: string): ToolResult;
|
|
14
|
+
/**
|
|
15
|
+
* Create an error result.
|
|
16
|
+
* Sets `isError: true` and prepends "Error: " to the message.
|
|
17
|
+
*
|
|
18
|
+
* @param message - Human-readable error description.
|
|
19
|
+
*/
|
|
20
|
+
export declare function errorResult(message: string): ToolResult;
|
|
21
|
+
/**
|
|
22
|
+
* Create a result containing a base64-encoded image.
|
|
23
|
+
*
|
|
24
|
+
* @param mediaType - MIME type (e.g. 'image/png', 'image/jpeg').
|
|
25
|
+
* @param data - Base64-encoded image data.
|
|
26
|
+
*/
|
|
27
|
+
export declare function imageResult(mediaType: string, data: string): ToolResult;
|
|
28
|
+
/**
|
|
29
|
+
* Create a result with multiple content blocks.
|
|
30
|
+
*
|
|
31
|
+
* @param blocks - Array of content blocks.
|
|
32
|
+
* @param isError - Whether this result represents an error.
|
|
33
|
+
*/
|
|
34
|
+
export declare function multiResult(blocks: ToolResultContent[], isError?: boolean): ToolResult;
|
|
35
|
+
/**
|
|
36
|
+
* Configuration for `defineTool()` — all fields that make up an AgentTool.
|
|
37
|
+
*/
|
|
38
|
+
export interface DefineToolConfig<TParams = Record<string, unknown>> {
|
|
39
|
+
name: string;
|
|
40
|
+
label: string;
|
|
41
|
+
description: string;
|
|
42
|
+
parameters: JSONSchema7;
|
|
43
|
+
execute: ToolExecuteFn<TParams>;
|
|
44
|
+
tags?: string[];
|
|
45
|
+
bridgeable?: boolean;
|
|
46
|
+
bridgeName?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Convenience builder that creates a fully-typed AgentTool from a
|
|
50
|
+
* plain configuration object.
|
|
51
|
+
*
|
|
52
|
+
* Usage:
|
|
53
|
+
* ```ts
|
|
54
|
+
* const myTool = defineTool({
|
|
55
|
+
* name: 'greet',
|
|
56
|
+
* label: 'Greeter',
|
|
57
|
+
* description: 'Greets the user.',
|
|
58
|
+
* parameters: {
|
|
59
|
+
* type: 'object',
|
|
60
|
+
* properties: { name: { type: 'string' } },
|
|
61
|
+
* required: ['name'],
|
|
62
|
+
* },
|
|
63
|
+
* async execute(_id, params, _ctx) {
|
|
64
|
+
* return textResult(`Hello, ${params.name}!`);
|
|
65
|
+
* },
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function defineTool<TParams = Record<string, unknown>>(config: DefineToolConfig<TParams>): AgentTool<TParams>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool System — Convenience Helpers
|
|
3
|
+
*
|
|
4
|
+
* Provides factory functions for creating ToolResult values and
|
|
5
|
+
* a builder function for defining tools with less boilerplate.
|
|
6
|
+
*/
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Result Factories
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/**
|
|
11
|
+
* Create a successful text result.
|
|
12
|
+
*
|
|
13
|
+
* @param text - The text content to return.
|
|
14
|
+
*/
|
|
15
|
+
export function textResult(text) {
|
|
16
|
+
return {
|
|
17
|
+
content: [{ type: 'text', text }],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create an error result.
|
|
22
|
+
* Sets `isError: true` and prepends "Error: " to the message.
|
|
23
|
+
*
|
|
24
|
+
* @param message - Human-readable error description.
|
|
25
|
+
*/
|
|
26
|
+
export function errorResult(message) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
29
|
+
isError: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Create a result containing a base64-encoded image.
|
|
34
|
+
*
|
|
35
|
+
* @param mediaType - MIME type (e.g. 'image/png', 'image/jpeg').
|
|
36
|
+
* @param data - Base64-encoded image data.
|
|
37
|
+
*/
|
|
38
|
+
export function imageResult(mediaType, data) {
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: 'image', mediaType, data }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create a result with multiple content blocks.
|
|
45
|
+
*
|
|
46
|
+
* @param blocks - Array of content blocks.
|
|
47
|
+
* @param isError - Whether this result represents an error.
|
|
48
|
+
*/
|
|
49
|
+
export function multiResult(blocks, isError) {
|
|
50
|
+
return {
|
|
51
|
+
content: blocks,
|
|
52
|
+
...(isError ? { isError } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Convenience builder that creates a fully-typed AgentTool from a
|
|
57
|
+
* plain configuration object.
|
|
58
|
+
*
|
|
59
|
+
* Usage:
|
|
60
|
+
* ```ts
|
|
61
|
+
* const myTool = defineTool({
|
|
62
|
+
* name: 'greet',
|
|
63
|
+
* label: 'Greeter',
|
|
64
|
+
* description: 'Greets the user.',
|
|
65
|
+
* parameters: {
|
|
66
|
+
* type: 'object',
|
|
67
|
+
* properties: { name: { type: 'string' } },
|
|
68
|
+
* required: ['name'],
|
|
69
|
+
* },
|
|
70
|
+
* async execute(_id, params, _ctx) {
|
|
71
|
+
* return textResult(`Hello, ${params.name}!`);
|
|
72
|
+
* },
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function defineTool(config) {
|
|
77
|
+
return {
|
|
78
|
+
name: config.name,
|
|
79
|
+
label: config.label,
|
|
80
|
+
description: config.description,
|
|
81
|
+
parameters: config.parameters,
|
|
82
|
+
execute: config.execute,
|
|
83
|
+
...(config.tags ? { tags: config.tags } : {}),
|
|
84
|
+
...(config.bridgeable !== undefined ? { bridgeable: config.bridgeable } : {}),
|
|
85
|
+
...(config.bridgeName ? { bridgeName: config.bridgeName } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|