@warlock.js/ai-tools 4.5.0
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 +22 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/cjs/index.cjs +2519 -0
- package/cjs/index.cjs.map +1 -0
- package/esm/contracts/http.type.d.mts +96 -0
- package/esm/contracts/http.type.d.mts.map +1 -0
- package/esm/contracts/index.d.mts +4 -0
- package/esm/contracts/mcp.type.d.mts +216 -0
- package/esm/contracts/mcp.type.d.mts.map +1 -0
- package/esm/contracts/utility.type.d.mts +96 -0
- package/esm/contracts/utility.type.d.mts.map +1 -0
- package/esm/contracts/web.type.d.mts +136 -0
- package/esm/contracts/web.type.d.mts.map +1 -0
- package/esm/errors.d.mts +225 -0
- package/esm/errors.d.mts.map +1 -0
- package/esm/errors.mjs +136 -0
- package/esm/errors.mjs.map +1 -0
- package/esm/http/http-request.d.mts +57 -0
- package/esm/http/http-request.d.mts.map +1 -0
- package/esm/http/http-request.mjs +221 -0
- package/esm/http/http-request.mjs.map +1 -0
- package/esm/index.d.mts +19 -0
- package/esm/index.mjs +15 -0
- package/esm/mcp/client.mjs +199 -0
- package/esm/mcp/client.mjs.map +1 -0
- package/esm/mcp/index.d.mts +43 -0
- package/esm/mcp/index.d.mts.map +1 -0
- package/esm/mcp/index.mjs +19 -0
- package/esm/mcp/index.mjs.map +1 -0
- package/esm/mcp/json-schema-to-standard.d.mts +34 -0
- package/esm/mcp/json-schema-to-standard.d.mts.map +1 -0
- package/esm/mcp/json-schema-to-standard.mjs +147 -0
- package/esm/mcp/json-schema-to-standard.mjs.map +1 -0
- package/esm/mcp/serve.d.mts +46 -0
- package/esm/mcp/serve.d.mts.map +1 -0
- package/esm/mcp/serve.mjs +264 -0
- package/esm/mcp/serve.mjs.map +1 -0
- package/esm/mcp/transport.d.mts +48 -0
- package/esm/mcp/transport.d.mts.map +1 -0
- package/esm/mcp/transport.mjs +381 -0
- package/esm/mcp/transport.mjs.map +1 -0
- package/esm/mcp/transport.type.d.mts +51 -0
- package/esm/mcp/transport.type.d.mts.map +1 -0
- package/esm/node_modules/@standard-schema/spec/dist/index.d.mts +80 -0
- package/esm/node_modules/@standard-schema/spec/dist/index.d.mts.map +1 -0
- package/esm/register.d.mts +55 -0
- package/esm/register.d.mts.map +1 -0
- package/esm/register.mjs +21 -0
- package/esm/register.mjs.map +1 -0
- package/esm/schema.mjs +127 -0
- package/esm/schema.mjs.map +1 -0
- package/esm/utility/calculator.d.mts +35 -0
- package/esm/utility/calculator.d.mts.map +1 -0
- package/esm/utility/calculator.mjs +272 -0
- package/esm/utility/calculator.mjs.map +1 -0
- package/esm/utility/date-time.d.mts +57 -0
- package/esm/utility/date-time.d.mts.map +1 -0
- package/esm/utility/date-time.mjs +193 -0
- package/esm/utility/date-time.mjs.map +1 -0
- package/esm/utility/index.d.mts +2 -0
- package/esm/utility/index.mjs +4 -0
- package/esm/utility/schema.mjs +114 -0
- package/esm/utility/schema.mjs.map +1 -0
- package/esm/web/fetch-url.d.mts +39 -0
- package/esm/web/fetch-url.d.mts.map +1 -0
- package/esm/web/fetch-url.mjs +228 -0
- package/esm/web/fetch-url.mjs.map +1 -0
- package/esm/web/index.d.mts +2 -0
- package/esm/web/index.mjs +4 -0
- package/esm/web/schema.mjs +86 -0
- package/esm/web/schema.mjs.map +1 -0
- package/esm/web/web-search.d.mts +38 -0
- package/esm/web/web-search.d.mts.map +1 -0
- package/esm/web/web-search.mjs +167 -0
- package/esm/web/web-search.mjs.map +1 -0
- package/llms-full.txt +326 -0
- package/llms.txt +11 -0
- package/package.json +45 -0
- package/skills/README.md +17 -0
- package/skills/connect-mcp-server/SKILL.md +98 -0
- package/skills/expose-as-mcp-server/SKILL.md +85 -0
- package/skills/use-web-and-http-tools/SKILL.md +125 -0
package/cjs/index.cjs
ADDED
|
@@ -0,0 +1,2519 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
let _warlock_js_ai = require("@warlock.js/ai");
|
|
3
|
+
let node_child_process = require("node:child_process");
|
|
4
|
+
let node_readline = require("node:readline");
|
|
5
|
+
|
|
6
|
+
//#region ../@warlock.js/ai-tools/src/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* The `calculator` tool could not evaluate an expression — it was not
|
|
9
|
+
* valid arithmetic, divided by zero, or overflowed to a non-finite
|
|
10
|
+
* value.
|
|
11
|
+
*
|
|
12
|
+
* **Surface.** Thrown inside the tool handler, where `tool()` wraps it
|
|
13
|
+
* into a `ToolExecutionError` whose message is preserved verbatim and
|
|
14
|
+
* reaches the model as `{ error }` data, so the agent self-corrects
|
|
15
|
+
* rather than crashing. Extends the framework {@link AIError} (category
|
|
16
|
+
* `"tool"` via code `TOOL_EXEC_FAILED`) so it flows through the same
|
|
17
|
+
* typed error contract as every other AI error; branch on `error.type`
|
|
18
|
+
* for the specific failure.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* if (error instanceof CalculatorError && error.type === "divide-by-zero") {
|
|
22
|
+
* // the expression divided by zero — ask the model to revise it
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
var CalculatorError = class extends _warlock_js_ai.AIError {
|
|
26
|
+
constructor(message, options) {
|
|
27
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
28
|
+
this.name = "CalculatorError";
|
|
29
|
+
this.type = options.type;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The `date_time` tool could not complete a call — a required field was
|
|
34
|
+
* missing or malformed, the unit/time zone was unrecognized, or the
|
|
35
|
+
* operation is unsupported.
|
|
36
|
+
*
|
|
37
|
+
* **Surface.** Thrown inside the tool handler, where `tool()` wraps it
|
|
38
|
+
* into a `ToolExecutionError` whose message reaches the model as
|
|
39
|
+
* `{ error }` data so the agent self-corrects. Extends the framework
|
|
40
|
+
* {@link AIError} (category `"tool"` via code `TOOL_EXEC_FAILED`); branch
|
|
41
|
+
* on `error.type` for the specific failure.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* if (error instanceof DateTimeError && error.type === "invalid-unit") {
|
|
45
|
+
* // the model passed an unknown unit — re-prompt with the allowed set
|
|
46
|
+
* }
|
|
47
|
+
*/
|
|
48
|
+
var DateTimeError = class extends _warlock_js_ai.AIError {
|
|
49
|
+
constructor(message, options) {
|
|
50
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
51
|
+
this.name = "DateTimeError";
|
|
52
|
+
this.type = options.type;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The MCP client's transport layer failed — it could not connect, the
|
|
57
|
+
* peer spoke malformed JSON-RPC, a call timed out, or the transport was
|
|
58
|
+
* already closed.
|
|
59
|
+
*
|
|
60
|
+
* **Surface.** Connection / handshake failures surface at
|
|
61
|
+
* agent-construction time (the caller `await`s `client.tools()`). A
|
|
62
|
+
* `tools/call` failure raised mid-run is wrapped by `tool()` into a
|
|
63
|
+
* `ToolExecutionError` and reaches the model as `{ error }` data, so the
|
|
64
|
+
* agent self-corrects rather than crashing. Extends the framework
|
|
65
|
+
* {@link AIError} (category `"tool"`, code `TOOL_EXEC_FAILED`) so it
|
|
66
|
+
* flows through the same typed error contract as every other AI error;
|
|
67
|
+
* branch on `error.type` for the specific failure.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* if (error instanceof McpTransportError && error.type === "timeout") {
|
|
71
|
+
* // the remote call exceeded its deadline — retry or escalate
|
|
72
|
+
* }
|
|
73
|
+
*/
|
|
74
|
+
var McpTransportError = class extends _warlock_js_ai.AIError {
|
|
75
|
+
constructor(message, options) {
|
|
76
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
77
|
+
this.name = "McpTransportError";
|
|
78
|
+
this.type = options.type;
|
|
79
|
+
this.method = options.method;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A web tool failed — a missing optional peer, an absent API key, a host
|
|
84
|
+
* rejected by the `allowHosts` guardrail, an unparseable URL, or a failed
|
|
85
|
+
* network call.
|
|
86
|
+
*
|
|
87
|
+
* **Surface.** Thrown from inside a tool's `execute`, so the framework's
|
|
88
|
+
* `tool()` wrapper catches it and surfaces it in the returned `{ error }`
|
|
89
|
+
* field (`invoke()` never throws) — the agent reads the failure as data
|
|
90
|
+
* and self-corrects rather than crashing. Extends the framework
|
|
91
|
+
* {@link AIError} (category `"tool"` via code `TOOL_EXEC_FAILED`) so it
|
|
92
|
+
* flows through the same typed error contract as every other AI error;
|
|
93
|
+
* branch on `error.type` for the specific failure.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* const { error } = await fetchTool.invoke({ url: "http://evil.test" });
|
|
97
|
+
* if (error instanceof WebToolError && error.type === "denied-host") {
|
|
98
|
+
* // the host was not in allowHosts — surfaced before any network call
|
|
99
|
+
* }
|
|
100
|
+
*/
|
|
101
|
+
var WebToolError = class extends _warlock_js_ai.AIError {
|
|
102
|
+
constructor(message, options) {
|
|
103
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
104
|
+
this.name = "WebToolError";
|
|
105
|
+
this.type = options.type;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* The `http_request` tool refused a call its construction-time policy
|
|
110
|
+
* does not permit — a disallowed method, a host outside the allowlist,
|
|
111
|
+
* or an unparseable URL. The rejection happens *before* any network
|
|
112
|
+
* request, so a guarded tool can never be coaxed into reaching an
|
|
113
|
+
* off-allowlist host (an SSRF guardrail).
|
|
114
|
+
*
|
|
115
|
+
* **Surface.** Thrown from inside the tool's `execute`, so the framework's
|
|
116
|
+
* `tool()` wrapper catches it and surfaces it in the returned `{ error }`
|
|
117
|
+
* field (`invoke()` never throws) — the agent reads the typed failure as
|
|
118
|
+
* data and self-corrects rather than crashing. Extends the framework
|
|
119
|
+
* {@link AIError} (category `"tool"` via code `TOOL_EXEC_FAILED`) so it
|
|
120
|
+
* flows through the same typed error contract as every other AI error;
|
|
121
|
+
* branch on `error.type` for the specific failure.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* const { error } = await httpTool.invoke({ url: "https://evil.test" });
|
|
125
|
+
* if (error instanceof HttpPolicyError && error.type === "host-not-allowed") {
|
|
126
|
+
* // the model tried to reach a host outside the configured allowlist
|
|
127
|
+
* }
|
|
128
|
+
*/
|
|
129
|
+
var HttpPolicyError = class extends _warlock_js_ai.AIError {
|
|
130
|
+
constructor(message, options) {
|
|
131
|
+
super("TOOL_EXEC_FAILED", message, options);
|
|
132
|
+
this.name = "HttpPolicyError";
|
|
133
|
+
this.type = options.type;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region ../@warlock.js/ai-tools/src/schema.ts
|
|
139
|
+
/**
|
|
140
|
+
* Tiny, dependency-free [Standard Schema](https://standardschema.dev)
|
|
141
|
+
* builders for the tools' input validation. The package pins only
|
|
142
|
+
* `@warlock.js/ai` as a required runtime peer and keeps every heavy
|
|
143
|
+
* dependency optional, so rather than pull in a schema library we
|
|
144
|
+
* hand-roll the few shapes the tools need — exactly the pattern
|
|
145
|
+
* `@warlock.js/ai`'s own `tool()` tests use. Each builder returns a
|
|
146
|
+
* `StandardSchemaV1`, which is what `tool({ input })` validates against
|
|
147
|
+
* before calling `execute`.
|
|
148
|
+
*
|
|
149
|
+
* These intentionally cover only the primitive cases the bundled tools
|
|
150
|
+
* require (`string`, an optional string drawn from a fixed set, an
|
|
151
|
+
* optional string-to-string record, and an opaque passthrough value),
|
|
152
|
+
* plus an `object` of fields. They are not a general-purpose validator.
|
|
153
|
+
*/
|
|
154
|
+
/** The vendor tag stamped on every issue these builders produce. */
|
|
155
|
+
const VENDOR$3 = "ai-tools";
|
|
156
|
+
/** Required string field — rejects anything that is not a string. */
|
|
157
|
+
function stringField$2() {
|
|
158
|
+
return (value, key) => {
|
|
159
|
+
if (typeof value === "string") return { value };
|
|
160
|
+
return { issues: [{
|
|
161
|
+
message: `"${key}" must be a string`,
|
|
162
|
+
path: [key]
|
|
163
|
+
}] };
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Optional string field constrained to a fixed set of allowed values.
|
|
168
|
+
* Accepts `undefined` (the property absent or explicitly undefined) or
|
|
169
|
+
* one of `allowed`; rejects any other string and every non-string type.
|
|
170
|
+
* Used for `method` so an out-of-vocabulary verb is a clean validation
|
|
171
|
+
* error rather than reaching the handler.
|
|
172
|
+
*
|
|
173
|
+
* @param allowed - The exhaustive set of permitted string literals.
|
|
174
|
+
*/
|
|
175
|
+
function optionalStringEnumField(allowed) {
|
|
176
|
+
return (value, key) => {
|
|
177
|
+
if (value === void 0) return { value: void 0 };
|
|
178
|
+
if (typeof value === "string" && allowed.includes(value)) return { value };
|
|
179
|
+
return { issues: [{
|
|
180
|
+
message: `"${key}" must be one of: ${allowed.join(", ")}`,
|
|
181
|
+
path: [key]
|
|
182
|
+
}] };
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Optional string-to-string record field — accepts `undefined` or a flat
|
|
187
|
+
* object whose every value is a string, and rejects arrays, non-objects,
|
|
188
|
+
* and any record with a non-string value. Used for per-call `headers`.
|
|
189
|
+
*/
|
|
190
|
+
function optionalStringRecordField() {
|
|
191
|
+
return (value, key) => {
|
|
192
|
+
if (value === void 0) return { value: void 0 };
|
|
193
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return { issues: [{
|
|
194
|
+
message: `"${key}" must be an object of string values when provided`,
|
|
195
|
+
path: [key]
|
|
196
|
+
}] };
|
|
197
|
+
const source = value;
|
|
198
|
+
const result = {};
|
|
199
|
+
for (const recordKey of Object.keys(source)) {
|
|
200
|
+
const entry = source[recordKey];
|
|
201
|
+
if (typeof entry !== "string") return { issues: [{
|
|
202
|
+
message: `"${key}.${recordKey}" must be a string`,
|
|
203
|
+
path: [key, recordKey]
|
|
204
|
+
}] };
|
|
205
|
+
result[recordKey] = entry;
|
|
206
|
+
}
|
|
207
|
+
return { value: result };
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Opaque passthrough field — accepts any value (including `undefined`)
|
|
212
|
+
* verbatim, performing no validation. Used for a request `body` whose
|
|
213
|
+
* shape the tool deliberately does not constrain: an object is later
|
|
214
|
+
* JSON-serialized, a string is sent as-is.
|
|
215
|
+
*/
|
|
216
|
+
function passthroughField() {
|
|
217
|
+
return (value) => ({ value });
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Build a {@link StandardSchemaV1} for a flat object whose every property
|
|
221
|
+
* is validated by a {@link FieldValidator}. The input must be a non-null
|
|
222
|
+
* object; each declared field is validated and the (possibly coerced)
|
|
223
|
+
* values are collected into the typed result. All field issues are merged
|
|
224
|
+
* so the caller sees every problem at once.
|
|
225
|
+
*
|
|
226
|
+
* `T` is constrained to `object` rather than `Record<string, unknown>` so
|
|
227
|
+
* the tool IO `interface`s (which carry no implicit string index
|
|
228
|
+
* signature) satisfy it directly — only the declared keys in `shape` are
|
|
229
|
+
* ever read, so a string index signature is never required.
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* const schema = objectSchema<{ url: string; method?: HttpMethod }>({
|
|
233
|
+
* url: stringField(),
|
|
234
|
+
* method: optionalStringEnumField(["GET", "POST"]),
|
|
235
|
+
* });
|
|
236
|
+
*/
|
|
237
|
+
function objectSchema$2(shape) {
|
|
238
|
+
return { "~standard": {
|
|
239
|
+
version: 1,
|
|
240
|
+
vendor: VENDOR$3,
|
|
241
|
+
validate(input) {
|
|
242
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
|
|
243
|
+
const source = input;
|
|
244
|
+
const issues = [];
|
|
245
|
+
const result = {};
|
|
246
|
+
for (const key of Object.keys(shape)) {
|
|
247
|
+
const field = shape[key];
|
|
248
|
+
const present = key in source;
|
|
249
|
+
const outcome = field(source[key], key);
|
|
250
|
+
if ("issues" in outcome) {
|
|
251
|
+
issues.push(...outcome.issues);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (outcome.value !== void 0 || present) result[key] = outcome.value;
|
|
255
|
+
}
|
|
256
|
+
if (issues.length > 0) return { issues };
|
|
257
|
+
return { value: result };
|
|
258
|
+
}
|
|
259
|
+
} };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
//#endregion
|
|
263
|
+
//#region ../@warlock.js/ai-tools/src/http/http-request.ts
|
|
264
|
+
/** Default tool name exposed to the LLM. */
|
|
265
|
+
const DEFAULT_NAME$4 = "http_request";
|
|
266
|
+
/** Default per-request wall-clock timeout, in milliseconds. */
|
|
267
|
+
const DEFAULT_TIMEOUT_MS$1 = 15e3;
|
|
268
|
+
/** Default hard cap on response-body bytes before truncation. */
|
|
269
|
+
const DEFAULT_MAX_BYTES$1 = 1e6;
|
|
270
|
+
/** The full set of HTTP methods, in the order they appear in {@link HttpMethod}. */
|
|
271
|
+
const ALL_METHODS = [
|
|
272
|
+
"GET",
|
|
273
|
+
"POST",
|
|
274
|
+
"PUT",
|
|
275
|
+
"PATCH",
|
|
276
|
+
"DELETE"
|
|
277
|
+
];
|
|
278
|
+
/** Methods that conventionally carry no request body — `body` is dropped for these. */
|
|
279
|
+
const BODYLESS_METHODS = new Set(["GET"]);
|
|
280
|
+
/**
|
|
281
|
+
* Standard Schema for {@link HttpRequestInput}. `url` is required;
|
|
282
|
+
* `method` is constrained to the canonical HTTP verb set (further
|
|
283
|
+
* narrowed to the tool's `allowMethods` at runtime); `headers` is an
|
|
284
|
+
* optional string-to-string record; `body` is an opaque passthrough the
|
|
285
|
+
* handler serializes based on its runtime type.
|
|
286
|
+
*/
|
|
287
|
+
const httpRequestInputSchema = objectSchema$2({
|
|
288
|
+
method: optionalStringEnumField(ALL_METHODS),
|
|
289
|
+
url: stringField$2(),
|
|
290
|
+
headers: optionalStringRecordField(),
|
|
291
|
+
body: passthroughField()
|
|
292
|
+
});
|
|
293
|
+
/**
|
|
294
|
+
* Resolve the request target. With a `baseUrl` configured the model
|
|
295
|
+
* supplies a path joined against it; otherwise the model's `url` must be
|
|
296
|
+
* an absolute `http(s)` URL. Throws a typed {@link HttpPolicyError} of
|
|
297
|
+
* type `"invalid-url"` when the result cannot be parsed or is not an
|
|
298
|
+
* `http`/`https` URL — surfaced as `{ error }` data, never a crash.
|
|
299
|
+
*/
|
|
300
|
+
function resolveUrl(rawUrl, baseUrl) {
|
|
301
|
+
let resolved;
|
|
302
|
+
try {
|
|
303
|
+
resolved = baseUrl !== void 0 ? new URL(rawUrl, baseUrl) : new URL(rawUrl);
|
|
304
|
+
} catch {
|
|
305
|
+
throw new HttpPolicyError(`http_request could not resolve a valid URL from "${rawUrl}"` + (baseUrl !== void 0 ? ` against base "${baseUrl}".` : "."), { type: "invalid-url" });
|
|
306
|
+
}
|
|
307
|
+
if (resolved.protocol !== "http:" && resolved.protocol !== "https:") throw new HttpPolicyError(`http_request only permits http(s) URLs; got "${resolved.protocol}".`, { type: "invalid-url" });
|
|
308
|
+
return resolved;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Read a `Response` body, capping at `maxBytes`. Returns the decoded text
|
|
312
|
+
* and whether it was cut off. Streams chunk-by-chunk so an oversized body
|
|
313
|
+
* is abandoned at the cap rather than fully buffered; falls back to
|
|
314
|
+
* `response.text()` (then a post-hoc byte slice) when the body is not a
|
|
315
|
+
* readable stream (e.g. a stubbed `Response` in tests).
|
|
316
|
+
*/
|
|
317
|
+
async function readCappedBody(response, maxBytes) {
|
|
318
|
+
const body = response.body;
|
|
319
|
+
if (!body) return {
|
|
320
|
+
text: "",
|
|
321
|
+
truncated: false
|
|
322
|
+
};
|
|
323
|
+
const decoder = new TextDecoder();
|
|
324
|
+
const reader = body.getReader();
|
|
325
|
+
let received = 0;
|
|
326
|
+
let truncated = false;
|
|
327
|
+
let text = "";
|
|
328
|
+
try {
|
|
329
|
+
for (;;) {
|
|
330
|
+
const { done, value } = await reader.read();
|
|
331
|
+
if (done) break;
|
|
332
|
+
if (!value) continue;
|
|
333
|
+
const remaining = maxBytes - received;
|
|
334
|
+
if (value.byteLength > remaining) {
|
|
335
|
+
text += decoder.decode(value.subarray(0, remaining), { stream: true });
|
|
336
|
+
received = maxBytes;
|
|
337
|
+
truncated = true;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
text += decoder.decode(value, { stream: true });
|
|
341
|
+
received += value.byteLength;
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
await reader.cancel().catch(() => void 0);
|
|
345
|
+
reader.releaseLock();
|
|
346
|
+
}
|
|
347
|
+
text += decoder.decode();
|
|
348
|
+
return {
|
|
349
|
+
text,
|
|
350
|
+
truncated
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Decide whether a response's `content-type` indicates JSON. Matches
|
|
355
|
+
* `application/json` and the `+json` structured-suffix convention
|
|
356
|
+
* (e.g. `application/vnd.api+json`), case-insensitively.
|
|
357
|
+
*/
|
|
358
|
+
function isJsonContentType(contentType) {
|
|
359
|
+
if (!contentType) return false;
|
|
360
|
+
const value = contentType.toLowerCase();
|
|
361
|
+
return value.includes("application/json") || value.includes("+json");
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Build the agent-facing `http_request` tool — a guarded HTTP/REST client
|
|
365
|
+
* over the global `fetch`. The `options` bound what the model may do; the
|
|
366
|
+
* model supplies the per-call URL / method / headers / body within those
|
|
367
|
+
* rails.
|
|
368
|
+
*
|
|
369
|
+
* **Guardrails (all enforced before the network call).**
|
|
370
|
+
* - **Method allowlist** — `allowMethods` (default `["GET"]`). A method
|
|
371
|
+
* outside the list is rejected with a typed
|
|
372
|
+
* {@link HttpPolicyError} (`type: "method-not-allowed"`).
|
|
373
|
+
* - **Host allowlist** — when `allowHosts` is set, any other host is
|
|
374
|
+
* rejected (`type: "host-not-allowed"`), an SSRF guardrail.
|
|
375
|
+
* - **`baseUrl` join** — when configured, the model passes a path that
|
|
376
|
+
* is resolved against `baseUrl`; otherwise it must pass an absolute
|
|
377
|
+
* `http(s)` URL. An unresolvable URL is rejected
|
|
378
|
+
* (`type: "invalid-url"`).
|
|
379
|
+
*
|
|
380
|
+
* **Request shaping.** Static `options.headers` are merged under the
|
|
381
|
+
* per-call `headers` (the per-call value wins). An object `body` is
|
|
382
|
+
* JSON-serialized with a `content-type: application/json` default; a
|
|
383
|
+
* string `body` is sent verbatim; `body` is dropped for bodyless methods
|
|
384
|
+
* (`GET`). The call is bounded by `timeoutMs` (default `15_000`) via an
|
|
385
|
+
* `AbortController`, also wired to `ctx.signal` for cooperative
|
|
386
|
+
* cancellation.
|
|
387
|
+
*
|
|
388
|
+
* **Response shaping.** Headers are returned with lower-cased keys. The
|
|
389
|
+
* body is read up to `maxBytes` (default `1_000_000`) and JSON-parsed
|
|
390
|
+
* when the response `content-type` is JSON, otherwise returned as text;
|
|
391
|
+
* `truncated` is `true` when the body was cut off at the cap (a truncated
|
|
392
|
+
* JSON body is returned as the raw partial string, since it can no longer
|
|
393
|
+
* be parsed).
|
|
394
|
+
*
|
|
395
|
+
* **Errors flow as data.** Every guardrail rejection and network failure
|
|
396
|
+
* is thrown inside `execute`; the framework's `tool()` wrapper catches it
|
|
397
|
+
* and surfaces it in the returned `{ error }` field, so the agent reads
|
|
398
|
+
* the failure and self-corrects rather than crashing.
|
|
399
|
+
*
|
|
400
|
+
* @param options - Construction-time policy bounding the tool.
|
|
401
|
+
* @returns A {@link ToolContract} the agent can call as `http_request`.
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* const stripe = httpRequestTool({
|
|
405
|
+
* baseUrl: "https://api.stripe.com",
|
|
406
|
+
* allowHosts: ["api.stripe.com"],
|
|
407
|
+
* allowMethods: ["GET", "POST"],
|
|
408
|
+
* headers: { authorization: `Bearer ${process.env.STRIPE_KEY}` },
|
|
409
|
+
* });
|
|
410
|
+
* const { data } = await stripe.invoke({ method: "GET", url: "/v1/charges" });
|
|
411
|
+
*/
|
|
412
|
+
function httpRequestTool(options = {}) {
|
|
413
|
+
const allowMethods = options.allowMethods ?? ["GET"];
|
|
414
|
+
const allowedMethodSet = new Set(allowMethods);
|
|
415
|
+
const allowHostSet = options.allowHosts ? new Set(options.allowHosts) : void 0;
|
|
416
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS$1;
|
|
417
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES$1;
|
|
418
|
+
const staticHeaders = options.headers;
|
|
419
|
+
return (0, _warlock_js_ai.tool)({
|
|
420
|
+
name: options.name ?? DEFAULT_NAME$4,
|
|
421
|
+
description: "Issue an HTTP request and return the status, response headers, and parsed body. Allowed methods and hosts are restricted by the tool's configuration; a request outside those rails is rejected before any network call. Pass an object body to send JSON, or a string to send it verbatim. The response body is JSON-parsed when the content-type is JSON, otherwise returned as text, and is capped — `truncated` is true when the body was cut off.",
|
|
422
|
+
action: (input) => `Requesting ${input.method ?? "GET"} ${input.url}`,
|
|
423
|
+
input: httpRequestInputSchema,
|
|
424
|
+
async execute(input, ctx) {
|
|
425
|
+
const method = input.method ?? "GET";
|
|
426
|
+
if (!allowedMethodSet.has(method)) throw new HttpPolicyError(`http_request method "${method}" is not allowed. Permitted methods: ${[...allowedMethodSet].join(", ")}.`, { type: "method-not-allowed" });
|
|
427
|
+
const url = resolveUrl(input.url, options.baseUrl);
|
|
428
|
+
if (allowHostSet && !allowHostSet.has(url.hostname)) throw new HttpPolicyError(`http_request host "${url.hostname}" is not in the allowlist. Permitted hosts: ${[...allowHostSet].join(", ")}.`, { type: "host-not-allowed" });
|
|
429
|
+
const headers = {
|
|
430
|
+
...staticHeaders,
|
|
431
|
+
...input.headers
|
|
432
|
+
};
|
|
433
|
+
let body;
|
|
434
|
+
if (!BODYLESS_METHODS.has(method) && input.body !== void 0) if (typeof input.body === "string") body = input.body;
|
|
435
|
+
else {
|
|
436
|
+
body = JSON.stringify(input.body);
|
|
437
|
+
if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) headers["content-type"] = "application/json";
|
|
438
|
+
}
|
|
439
|
+
const controller = new AbortController();
|
|
440
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
441
|
+
const onAbort = () => controller.abort();
|
|
442
|
+
if (ctx?.signal) if (ctx.signal.aborted) controller.abort();
|
|
443
|
+
else ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
444
|
+
let response;
|
|
445
|
+
try {
|
|
446
|
+
response = await fetch(url, {
|
|
447
|
+
method,
|
|
448
|
+
headers,
|
|
449
|
+
body,
|
|
450
|
+
signal: controller.signal
|
|
451
|
+
});
|
|
452
|
+
} finally {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
ctx?.signal?.removeEventListener("abort", onAbort);
|
|
455
|
+
}
|
|
456
|
+
const responseHeaders = {};
|
|
457
|
+
response.headers.forEach((value, key) => {
|
|
458
|
+
responseHeaders[key.toLowerCase()] = value;
|
|
459
|
+
});
|
|
460
|
+
const { text, truncated } = await readCappedBody(response, maxBytes);
|
|
461
|
+
let parsedBody = text;
|
|
462
|
+
if (!truncated && isJsonContentType(responseHeaders["content-type"]) && text.length > 0) try {
|
|
463
|
+
parsedBody = JSON.parse(text);
|
|
464
|
+
} catch {
|
|
465
|
+
parsedBody = text;
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
status: response.status,
|
|
469
|
+
headers: responseHeaders,
|
|
470
|
+
body: parsedBody,
|
|
471
|
+
truncated
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region ../@warlock.js/ai-tools/src/mcp/json-schema-to-standard.ts
|
|
479
|
+
/**
|
|
480
|
+
* The inverse of `@warlock.js/ai`'s `extractJsonSchema` (which goes
|
|
481
|
+
* Standard Schema → JSON Schema). Here we wrap a raw JSON Schema as a
|
|
482
|
+
* {@link StandardSchemaV1} whose `~standard.validate` runs a lazily-imported
|
|
483
|
+
* Ajv validator — so an MCP server's `inputSchema` (JSON Schema) becomes a
|
|
484
|
+
* `ToolConfig.input` the `tool()` factory can validate against.
|
|
485
|
+
*
|
|
486
|
+
* Ajv is an OPTIONAL peer, lazy-imported on first validate following the
|
|
487
|
+
* langfuse/readability pattern: a missing peer surfaces a curated install
|
|
488
|
+
* string (via the returned issues), never a raw module-resolution stack.
|
|
489
|
+
*/
|
|
490
|
+
/** The structural vendor template, mirroring `passthroughSchema()`. */
|
|
491
|
+
const VENDOR$2 = "warlock-ai";
|
|
492
|
+
let AjvSdk;
|
|
493
|
+
let ajvInstance;
|
|
494
|
+
let isAjvAvailable;
|
|
495
|
+
let loadingPromise$1;
|
|
496
|
+
const AJV_INSTALL_INSTRUCTIONS = `
|
|
497
|
+
The MCP client's JSON-Schema validation requires the ajv package.
|
|
498
|
+
Install it with:
|
|
499
|
+
|
|
500
|
+
npm install ajv
|
|
501
|
+
|
|
502
|
+
Or with your preferred package manager:
|
|
503
|
+
|
|
504
|
+
pnpm add ajv
|
|
505
|
+
yarn add ajv
|
|
506
|
+
`.trim();
|
|
507
|
+
/**
|
|
508
|
+
* Settle the lazy import of `ajv` once, concurrency-safe. A bare `catch`
|
|
509
|
+
* flips the availability flag to `false`; the curated install string then
|
|
510
|
+
* surfaces at validate time as a Standard Schema issue, never a raw
|
|
511
|
+
* module-resolution error. The constructed `Ajv` instance is cached and
|
|
512
|
+
* reused for every schema compile.
|
|
513
|
+
*/
|
|
514
|
+
async function loadAjv() {
|
|
515
|
+
if (isAjvAvailable !== void 0) return;
|
|
516
|
+
if (loadingPromise$1) return loadingPromise$1;
|
|
517
|
+
loadingPromise$1 = (async () => {
|
|
518
|
+
try {
|
|
519
|
+
AjvSdk = await import("ajv");
|
|
520
|
+
ajvInstance = new (AjvSdk.default ?? AjvSdk)({
|
|
521
|
+
allErrors: true,
|
|
522
|
+
strict: false
|
|
523
|
+
});
|
|
524
|
+
isAjvAvailable = true;
|
|
525
|
+
} catch {
|
|
526
|
+
isAjvAvailable = false;
|
|
527
|
+
}
|
|
528
|
+
})();
|
|
529
|
+
return loadingPromise$1;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Compile a JSON Schema with the shared Ajv instance, caching the compiled
|
|
533
|
+
* validator on a closure so repeated validations don't recompile. A schema
|
|
534
|
+
* Ajv itself rejects at compile time (an invalid meta-schema) degrades to
|
|
535
|
+
* an accept-all validator so a malformed remote schema can't wedge the
|
|
536
|
+
* tool — the server, not us, owns its schema's correctness.
|
|
537
|
+
*/
|
|
538
|
+
function makeCompiler(schema) {
|
|
539
|
+
let compiled;
|
|
540
|
+
let attempted = false;
|
|
541
|
+
return () => {
|
|
542
|
+
if (attempted) return compiled;
|
|
543
|
+
attempted = true;
|
|
544
|
+
if (!ajvInstance) return;
|
|
545
|
+
try {
|
|
546
|
+
compiled = ajvInstance.compile(schema);
|
|
547
|
+
} catch {
|
|
548
|
+
compiled = void 0;
|
|
549
|
+
}
|
|
550
|
+
return compiled;
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Wrap a raw JSON Schema as a {@link StandardSchemaV1} whose
|
|
555
|
+
* `~standard.validate` runs Ajv. The shape mirrors `passthroughSchema()`
|
|
556
|
+
* (`{ "~standard": { version: 1, vendor, validate } }`) so it drops into
|
|
557
|
+
* `tool({ input })` exactly like a native seal schema.
|
|
558
|
+
*
|
|
559
|
+
* Validation behavior:
|
|
560
|
+
* - **Valid input** → `{ value }` (the input is passed through unchanged;
|
|
561
|
+
* Ajv validates, it does not transform).
|
|
562
|
+
* - **Invalid input** → `{ issues }` carrying Ajv's `instancePath` +
|
|
563
|
+
* message per failure, so `tool()` produces a `SchemaValidationError`.
|
|
564
|
+
* - **Missing `ajv` peer** → a single issue carrying the curated install
|
|
565
|
+
* string, surfaced the same way (a developer-facing message in logs).
|
|
566
|
+
* - **No / empty schema** → an accept-all passthrough (an MCP tool may
|
|
567
|
+
* advertise no `inputSchema`).
|
|
568
|
+
*
|
|
569
|
+
* @param schema - The JSON Schema (an MCP tool's `inputSchema`), or
|
|
570
|
+
* `undefined` for a no-argument tool.
|
|
571
|
+
* @returns A `StandardSchemaV1<TInput>` ready for `tool({ input })`.
|
|
572
|
+
*
|
|
573
|
+
* @example
|
|
574
|
+
* const input = jsonSchemaToStandard<{ q: string }>({
|
|
575
|
+
* type: "object",
|
|
576
|
+
* properties: { q: { type: "string" } },
|
|
577
|
+
* required: ["q"],
|
|
578
|
+
* });
|
|
579
|
+
*/
|
|
580
|
+
function jsonSchemaToStandard(schema) {
|
|
581
|
+
if (!schema || Object.keys(schema).length === 0) return { "~standard": {
|
|
582
|
+
version: 1,
|
|
583
|
+
vendor: VENDOR$2,
|
|
584
|
+
validate: (value) => ({ value })
|
|
585
|
+
} };
|
|
586
|
+
const compile = makeCompiler(schema);
|
|
587
|
+
return { "~standard": {
|
|
588
|
+
version: 1,
|
|
589
|
+
vendor: VENDOR$2,
|
|
590
|
+
async validate(value) {
|
|
591
|
+
await loadAjv();
|
|
592
|
+
if (!isAjvAvailable) return { issues: [{ message: AJV_INSTALL_INSTRUCTIONS }] };
|
|
593
|
+
const validator = compile();
|
|
594
|
+
if (!validator) return { value };
|
|
595
|
+
if (validator(value)) return { value };
|
|
596
|
+
const issues = (validator.errors ?? []).map((error) => ({
|
|
597
|
+
message: formatAjvError(error),
|
|
598
|
+
path: pathFromInstancePath(error.instancePath)
|
|
599
|
+
}));
|
|
600
|
+
return { issues: issues.length > 0 ? issues : [{ message: "input failed JSON Schema validation" }] };
|
|
601
|
+
}
|
|
602
|
+
} };
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Render one Ajv error into a human-readable issue message. Prefixes the
|
|
606
|
+
* failing instance path (when present) so the model can see WHICH field
|
|
607
|
+
* was wrong, e.g. `/query: must be string`.
|
|
608
|
+
*/
|
|
609
|
+
function formatAjvError(error) {
|
|
610
|
+
return `${error.instancePath ? `${error.instancePath}: ` : ""}${error.message ?? `failed "${error.keyword ?? "validation"}"`}`;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Convert an Ajv `instancePath` (a JSON-Pointer like `/items/0/name`) into
|
|
614
|
+
* the Standard Schema `path` segment array (`["items", "0", "name"]`).
|
|
615
|
+
* Empty paths (a root-level failure) become an empty array.
|
|
616
|
+
*/
|
|
617
|
+
function pathFromInstancePath(instancePath) {
|
|
618
|
+
if (!instancePath) return [];
|
|
619
|
+
return instancePath.split("/").filter((segment) => segment.length > 0).map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region ../@warlock.js/ai-tools/src/mcp/transport.ts
|
|
624
|
+
/** Default per-request wait before a transport call is abandoned. */
|
|
625
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
626
|
+
/** The JSON-RPC version literal every outbound message carries. */
|
|
627
|
+
const JSONRPC_VERSION$1 = "2.0";
|
|
628
|
+
/**
|
|
629
|
+
* Wire a per-call timeout and an optional caller `AbortSignal` onto a
|
|
630
|
+
* pending request, returning a `cleanup()` that tears both down. The
|
|
631
|
+
* `onSettle` callback removes the pending entry from whatever registry the
|
|
632
|
+
* transport keeps so a late response can't double-settle.
|
|
633
|
+
*/
|
|
634
|
+
function armCall(reject, method, options, onSettle) {
|
|
635
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
636
|
+
const timer = setTimeout(() => {
|
|
637
|
+
onSettle();
|
|
638
|
+
reject(new McpTransportError(`MCP request "${method}" timed out after ${timeoutMs}ms.`, {
|
|
639
|
+
type: "timeout",
|
|
640
|
+
method
|
|
641
|
+
}));
|
|
642
|
+
}, timeoutMs);
|
|
643
|
+
const onAbort = () => {
|
|
644
|
+
cleanup();
|
|
645
|
+
reject(new McpTransportError(`MCP request "${method}" was aborted.`, {
|
|
646
|
+
type: "closed",
|
|
647
|
+
method
|
|
648
|
+
}));
|
|
649
|
+
};
|
|
650
|
+
const signal = options?.signal;
|
|
651
|
+
if (signal) if (signal.aborted) queueMicrotask(onAbort);
|
|
652
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
653
|
+
function cleanup() {
|
|
654
|
+
clearTimeout(timer);
|
|
655
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
656
|
+
}
|
|
657
|
+
return cleanup;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The stdio transport — spawns a child process and speaks JSON-RPC over
|
|
661
|
+
* its stdin/stdout, one JSON object per line (newline-delimited framing).
|
|
662
|
+
* Uses only Node built-ins (`node:child_process` + `node:readline`); no
|
|
663
|
+
* dependency.
|
|
664
|
+
*
|
|
665
|
+
* Constructed via {@link createStdioTransport}; the class itself is
|
|
666
|
+
* internal.
|
|
667
|
+
*/
|
|
668
|
+
var StdioTransport = class {
|
|
669
|
+
constructor(transport) {
|
|
670
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
671
|
+
this.nextId = 1;
|
|
672
|
+
this.closed = false;
|
|
673
|
+
let child;
|
|
674
|
+
try {
|
|
675
|
+
child = (0, node_child_process.spawn)(transport.command, transport.args ?? [], {
|
|
676
|
+
env: transport.env,
|
|
677
|
+
stdio: [
|
|
678
|
+
"pipe",
|
|
679
|
+
"pipe",
|
|
680
|
+
"pipe"
|
|
681
|
+
]
|
|
682
|
+
});
|
|
683
|
+
} catch (cause) {
|
|
684
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
685
|
+
throw new McpTransportError(`MCP stdio transport could not spawn "${transport.command}": ${message}`, {
|
|
686
|
+
type: "connect",
|
|
687
|
+
cause
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
this.child = child;
|
|
691
|
+
this.reader = (0, node_readline.createInterface)({ input: child.stdout });
|
|
692
|
+
this.reader.on("line", (line) => this.onLine(line));
|
|
693
|
+
child.on("exit", (code) => this.failAll("connect", `child exited with code ${code ?? "null"}`));
|
|
694
|
+
child.on("error", (error) => this.failAll("connect", error.message));
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Parse one stdout line and route it to its pending request. Non-JSON
|
|
698
|
+
* lines (a server logging to stdout) and messages with no matching `id`
|
|
699
|
+
* (notifications, stray responses) are ignored — robustness over strictness.
|
|
700
|
+
*/
|
|
701
|
+
onLine(line) {
|
|
702
|
+
const trimmed = line.trim();
|
|
703
|
+
if (!trimmed) return;
|
|
704
|
+
let message;
|
|
705
|
+
try {
|
|
706
|
+
message = JSON.parse(trimmed);
|
|
707
|
+
} catch {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (message.id === void 0 || message.id === null) return;
|
|
711
|
+
const call = this.pending.get(message.id);
|
|
712
|
+
if (!call) return;
|
|
713
|
+
this.pending.delete(message.id);
|
|
714
|
+
call.cleanup();
|
|
715
|
+
call.resolve(message);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Reject every pending call (and mark the transport unusable) when the
|
|
719
|
+
* child dies or errors — so a hung server can never leave a caller
|
|
720
|
+
* waiting forever.
|
|
721
|
+
*/
|
|
722
|
+
failAll(type, reason) {
|
|
723
|
+
this.closed = true;
|
|
724
|
+
for (const [id, call] of this.pending) {
|
|
725
|
+
this.pending.delete(id);
|
|
726
|
+
call.cleanup();
|
|
727
|
+
call.reject(new McpTransportError(`MCP stdio transport failed: ${reason}.`, { type }));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
request(request, options) {
|
|
731
|
+
if (this.closed) return Promise.reject(new McpTransportError("MCP stdio transport is closed.", {
|
|
732
|
+
type: "closed",
|
|
733
|
+
method: request.method
|
|
734
|
+
}));
|
|
735
|
+
const id = request.id;
|
|
736
|
+
return new Promise((resolve, reject) => {
|
|
737
|
+
const cleanup = armCall(reject, request.method, options, () => this.pending.delete(id));
|
|
738
|
+
this.pending.set(id, {
|
|
739
|
+
resolve: (response) => resolve(response),
|
|
740
|
+
reject,
|
|
741
|
+
cleanup
|
|
742
|
+
});
|
|
743
|
+
try {
|
|
744
|
+
this.child.stdin.write(`${JSON.stringify(request)}\n`);
|
|
745
|
+
} catch (cause) {
|
|
746
|
+
this.pending.delete(id);
|
|
747
|
+
cleanup();
|
|
748
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
749
|
+
reject(new McpTransportError(`MCP stdio transport failed to write request "${request.method}": ${message}`, {
|
|
750
|
+
type: "closed",
|
|
751
|
+
method: request.method,
|
|
752
|
+
cause
|
|
753
|
+
}));
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
async notify(method, params) {
|
|
758
|
+
if (this.closed) throw new McpTransportError("MCP stdio transport is closed.", {
|
|
759
|
+
type: "closed",
|
|
760
|
+
method
|
|
761
|
+
});
|
|
762
|
+
const notification = {
|
|
763
|
+
jsonrpc: JSONRPC_VERSION$1,
|
|
764
|
+
method,
|
|
765
|
+
params
|
|
766
|
+
};
|
|
767
|
+
this.child.stdin.write(`${JSON.stringify(notification)}\n`);
|
|
768
|
+
}
|
|
769
|
+
/** Allocate the next outbound request id. */
|
|
770
|
+
allocateId() {
|
|
771
|
+
return this.nextId++;
|
|
772
|
+
}
|
|
773
|
+
async close() {
|
|
774
|
+
if (this.closed) return;
|
|
775
|
+
this.closed = true;
|
|
776
|
+
this.reader.close();
|
|
777
|
+
this.failAll("closed", "transport closed by caller");
|
|
778
|
+
this.child.kill();
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
/**
|
|
782
|
+
* The Streamable HTTP transport — POSTs each JSON-RPC request to the
|
|
783
|
+
* server endpoint over the global `fetch` (Node 18+) and reads the single
|
|
784
|
+
* JSON response. No SSE-legacy, no WebSocket. Static `headers` (e.g. auth)
|
|
785
|
+
* are sent with every request.
|
|
786
|
+
*
|
|
787
|
+
* Constructed via {@link createHttpTransport}; the class itself is internal.
|
|
788
|
+
*/
|
|
789
|
+
var HttpTransport = class {
|
|
790
|
+
constructor(transport) {
|
|
791
|
+
this.nextId = 1;
|
|
792
|
+
this.url = transport.url;
|
|
793
|
+
this.headers = {
|
|
794
|
+
"content-type": "application/json",
|
|
795
|
+
accept: "application/json, text/event-stream",
|
|
796
|
+
...transport.headers
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
async request(request, options) {
|
|
800
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
801
|
+
const controller = new AbortController();
|
|
802
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
803
|
+
const onAbort = () => controller.abort();
|
|
804
|
+
const signal = options?.signal;
|
|
805
|
+
if (signal) if (signal.aborted) controller.abort();
|
|
806
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
807
|
+
let response;
|
|
808
|
+
try {
|
|
809
|
+
response = await fetch(this.url, {
|
|
810
|
+
method: "POST",
|
|
811
|
+
headers: this.headers,
|
|
812
|
+
body: JSON.stringify(request),
|
|
813
|
+
signal: controller.signal
|
|
814
|
+
});
|
|
815
|
+
} catch (cause) {
|
|
816
|
+
const aborted = controller.signal.aborted;
|
|
817
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
818
|
+
throw new McpTransportError(aborted ? `MCP request "${request.method}" timed out or was aborted after ${timeoutMs}ms.` : `MCP http transport request "${request.method}" failed: ${message}`, {
|
|
819
|
+
type: aborted ? "timeout" : "connect",
|
|
820
|
+
method: request.method,
|
|
821
|
+
cause
|
|
822
|
+
});
|
|
823
|
+
} finally {
|
|
824
|
+
clearTimeout(timer);
|
|
825
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
826
|
+
}
|
|
827
|
+
if (!response.ok) throw new McpTransportError(`MCP http transport request "${request.method}" returned HTTP ${response.status}.`, {
|
|
828
|
+
type: "connect",
|
|
829
|
+
method: request.method,
|
|
830
|
+
context: { status: response.status }
|
|
831
|
+
});
|
|
832
|
+
return this.parseBody(response, request.method);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Parse the HTTP response body into a JSON-RPC response. Streamable HTTP
|
|
836
|
+
* may answer with either `application/json` (a single response object)
|
|
837
|
+
* or `text/event-stream` (SSE frames); we read the body as text and
|
|
838
|
+
* extract the first JSON object, supporting the common `data: {...}`
|
|
839
|
+
* SSE line shape without a streaming parser.
|
|
840
|
+
*/
|
|
841
|
+
async parseBody(response, method) {
|
|
842
|
+
const raw = await response.text();
|
|
843
|
+
const jsonText = (response.headers.get("content-type")?.toLowerCase() ?? "").includes("text/event-stream") ? extractSseData(raw) : raw;
|
|
844
|
+
if (!jsonText) throw new McpTransportError(`MCP http transport got an empty response for "${method}".`, {
|
|
845
|
+
type: "protocol",
|
|
846
|
+
method
|
|
847
|
+
});
|
|
848
|
+
try {
|
|
849
|
+
return JSON.parse(jsonText);
|
|
850
|
+
} catch (cause) {
|
|
851
|
+
throw new McpTransportError(`MCP http transport got a non-JSON response for "${method}".`, {
|
|
852
|
+
type: "protocol",
|
|
853
|
+
method,
|
|
854
|
+
cause
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
async notify(method, params) {
|
|
859
|
+
const notification = {
|
|
860
|
+
jsonrpc: JSONRPC_VERSION$1,
|
|
861
|
+
method,
|
|
862
|
+
params
|
|
863
|
+
};
|
|
864
|
+
try {
|
|
865
|
+
await fetch(this.url, {
|
|
866
|
+
method: "POST",
|
|
867
|
+
headers: this.headers,
|
|
868
|
+
body: JSON.stringify(notification)
|
|
869
|
+
});
|
|
870
|
+
} catch (cause) {
|
|
871
|
+
throw new McpTransportError(`MCP http transport notification "${method}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
872
|
+
type: "connect",
|
|
873
|
+
method,
|
|
874
|
+
cause
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/** Allocate the next outbound request id. */
|
|
879
|
+
allocateId() {
|
|
880
|
+
return this.nextId++;
|
|
881
|
+
}
|
|
882
|
+
async close() {}
|
|
883
|
+
};
|
|
884
|
+
/**
|
|
885
|
+
* Pull the first `data:` JSON payload out of an SSE response body. MCP's
|
|
886
|
+
* Streamable HTTP transport answers a single request with one SSE frame
|
|
887
|
+
* carrying the JSON-RPC response; we take the first non-empty `data:`
|
|
888
|
+
* line. Returns an empty string when none is found.
|
|
889
|
+
*/
|
|
890
|
+
function extractSseData(body) {
|
|
891
|
+
for (const line of body.split(/\r?\n/)) {
|
|
892
|
+
const trimmed = line.trim();
|
|
893
|
+
if (trimmed.startsWith("data:")) {
|
|
894
|
+
const payload = trimmed.slice(5).trim();
|
|
895
|
+
if (payload && payload !== "[DONE]") return payload;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return "";
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Build the concrete {@link McpTransportClient} for an {@link McpTransport}
|
|
902
|
+
* config — a {@link StdioTransport} for `type: "stdio"`, an
|
|
903
|
+
* {@link HttpTransport} for `type: "http"`. The returned client also
|
|
904
|
+
* carries an `allocateId()` for the JSON-RPC client to mint request ids.
|
|
905
|
+
*
|
|
906
|
+
* @param transport - The transport config (discriminated by `type`).
|
|
907
|
+
* @returns A transport client paired with its id allocator.
|
|
908
|
+
*/
|
|
909
|
+
function createTransport(transport) {
|
|
910
|
+
if (transport.type === "stdio") return new StdioTransport(transport);
|
|
911
|
+
return new HttpTransport(transport);
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* A minimal JSON-RPC 2.0 request/response client over any
|
|
915
|
+
* {@link McpTransportClient}. Mints monotonic ids, frames the
|
|
916
|
+
* `{ jsonrpc, id, method, params }` envelope, and unwraps the response —
|
|
917
|
+
* translating a JSON-RPC `error` member into a typed
|
|
918
|
+
* {@link McpTransportError} so callers branch on `error.type` rather than
|
|
919
|
+
* parsing the wire.
|
|
920
|
+
*
|
|
921
|
+
* Constructed via {@link createJsonRpcClient}; the class itself is internal.
|
|
922
|
+
*/
|
|
923
|
+
var JsonRpcClient = class {
|
|
924
|
+
constructor(transport) {
|
|
925
|
+
this.transport = transport;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Issue a JSON-RPC `method` call and resolve with its `result`,
|
|
929
|
+
* throwing a typed {@link McpTransportError} on a JSON-RPC error member
|
|
930
|
+
* or a malformed response (neither `result` nor `error`).
|
|
931
|
+
*/
|
|
932
|
+
async call(method, params, options) {
|
|
933
|
+
const request = {
|
|
934
|
+
jsonrpc: JSONRPC_VERSION$1,
|
|
935
|
+
id: this.transport.allocateId(),
|
|
936
|
+
method,
|
|
937
|
+
params
|
|
938
|
+
};
|
|
939
|
+
const response = await this.transport.request(request, options);
|
|
940
|
+
if (response.error) throw new McpTransportError(`MCP "${method}" failed: ${response.error.message} (code ${response.error.code}).`, {
|
|
941
|
+
type: "protocol",
|
|
942
|
+
method,
|
|
943
|
+
context: { code: response.error.code },
|
|
944
|
+
cause: response.error.data
|
|
945
|
+
});
|
|
946
|
+
if (response.result === void 0) throw new McpTransportError(`MCP "${method}" returned a response with neither result nor error.`, {
|
|
947
|
+
type: "protocol",
|
|
948
|
+
method
|
|
949
|
+
});
|
|
950
|
+
return response.result;
|
|
951
|
+
}
|
|
952
|
+
/** Send a one-way JSON-RPC notification (no response awaited). */
|
|
953
|
+
notify(method, params) {
|
|
954
|
+
return this.transport.notify(method, params);
|
|
955
|
+
}
|
|
956
|
+
/** Close the underlying transport. */
|
|
957
|
+
close() {
|
|
958
|
+
return this.transport.close();
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
/**
|
|
962
|
+
* Build a {@link JsonRpcClientHandle} over a transport. Accepts either a
|
|
963
|
+
* pre-built {@link McpTransportClient} (the test seam — inject a scripted
|
|
964
|
+
* fake) or an {@link McpTransport} config, in which case the concrete
|
|
965
|
+
* transport is constructed via {@link createTransport}.
|
|
966
|
+
*
|
|
967
|
+
* When a bare {@link McpTransportClient} (without an `allocateId`) is
|
|
968
|
+
* injected, the client supplies its own monotonic id source.
|
|
969
|
+
*
|
|
970
|
+
* @param source - A transport client or an `McpTransport` config.
|
|
971
|
+
* @returns A JSON-RPC client handle.
|
|
972
|
+
*/
|
|
973
|
+
function createJsonRpcClient(source) {
|
|
974
|
+
return new JsonRpcClient(isTransportConfig(source) ? createTransport(source) : withIdAllocator(source));
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Distinguish an {@link McpTransport} config (a plain object with a `type`
|
|
978
|
+
* discriminator and no `request` method) from a built
|
|
979
|
+
* {@link McpTransportClient} (which exposes `request`).
|
|
980
|
+
*/
|
|
981
|
+
function isTransportConfig(source) {
|
|
982
|
+
return typeof source.request !== "function";
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Wrap an injected {@link McpTransportClient} that lacks its own
|
|
986
|
+
* `allocateId` with a monotonic id source, so the JSON-RPC client can mint
|
|
987
|
+
* request ids uniformly regardless of whether the transport was built here
|
|
988
|
+
* or supplied by a test.
|
|
989
|
+
*/
|
|
990
|
+
function withIdAllocator(client) {
|
|
991
|
+
const candidate = client;
|
|
992
|
+
if (typeof candidate.allocateId === "function") return candidate;
|
|
993
|
+
let nextId = 1;
|
|
994
|
+
return Object.assign(client, { allocateId: () => nextId++ });
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
//#endregion
|
|
998
|
+
//#region ../@warlock.js/ai-tools/src/mcp/client.ts
|
|
999
|
+
/** Default per-call timeout for `tools/call`. */
|
|
1000
|
+
const DEFAULT_CALL_TIMEOUT_MS = 3e4;
|
|
1001
|
+
/** The MCP protocol version this client advertises in `initialize`. */
|
|
1002
|
+
const PROTOCOL_VERSION$1 = "2025-06-18";
|
|
1003
|
+
/**
|
|
1004
|
+
* The internal {@link McpClient} implementation. Owns one JSON-RPC client
|
|
1005
|
+
* over a transport, runs the `initialize` handshake on first use, lists
|
|
1006
|
+
* the server's tools, and adapts each into a {@link ToolContract} whose
|
|
1007
|
+
* `execute` issues `tools/call`. The adapted contracts are cached after
|
|
1008
|
+
* the first `tools()` so repeat calls don't re-handshake.
|
|
1009
|
+
*
|
|
1010
|
+
* Constructed via {@link mcp}; the class itself is internal.
|
|
1011
|
+
*/
|
|
1012
|
+
var McpClientImpl = class {
|
|
1013
|
+
constructor(source, options = {}) {
|
|
1014
|
+
this.initialized = false;
|
|
1015
|
+
this.rpc = createJsonRpcClient(source);
|
|
1016
|
+
this.options = options;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Run the MCP `initialize` handshake exactly once, then send the
|
|
1020
|
+
* `notifications/initialized` notification the protocol requires before
|
|
1021
|
+
* any other request. Wraps a handshake failure as a typed
|
|
1022
|
+
* {@link McpTransportError} of type `"connect"`.
|
|
1023
|
+
*/
|
|
1024
|
+
async handshake() {
|
|
1025
|
+
if (this.initialized) return;
|
|
1026
|
+
try {
|
|
1027
|
+
await this.rpc.call("initialize", {
|
|
1028
|
+
protocolVersion: PROTOCOL_VERSION$1,
|
|
1029
|
+
capabilities: {},
|
|
1030
|
+
clientInfo: {
|
|
1031
|
+
name: "@warlock.js/ai-tools",
|
|
1032
|
+
version: "4.4.0"
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
await this.rpc.notify("notifications/initialized");
|
|
1036
|
+
} catch (cause) {
|
|
1037
|
+
if (cause instanceof McpTransportError) throw cause;
|
|
1038
|
+
throw new McpTransportError(`MCP initialize handshake failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
1039
|
+
type: "connect",
|
|
1040
|
+
method: "initialize",
|
|
1041
|
+
cause
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
this.initialized = true;
|
|
1045
|
+
}
|
|
1046
|
+
tools() {
|
|
1047
|
+
if (this.cached) return Promise.resolve(this.cached);
|
|
1048
|
+
if (this.pending) return this.pending;
|
|
1049
|
+
this.pending = this.listAndAdapt().then((tools) => {
|
|
1050
|
+
this.cached = tools;
|
|
1051
|
+
return tools;
|
|
1052
|
+
}).finally(() => {
|
|
1053
|
+
this.pending = void 0;
|
|
1054
|
+
});
|
|
1055
|
+
return this.pending;
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Handshake, `tools/list`, and adapt each descriptor into a
|
|
1059
|
+
* {@link ToolContract}, applying the `filter` and `namePrefix` options.
|
|
1060
|
+
*/
|
|
1061
|
+
async listAndAdapt() {
|
|
1062
|
+
await this.handshake();
|
|
1063
|
+
const descriptors = (await this.rpc.call("tools/list")).tools ?? [];
|
|
1064
|
+
const filter = this.options.filter;
|
|
1065
|
+
return (filter ? descriptors.filter((d) => filter(d.name)) : descriptors).map((descriptor) => this.adapt(descriptor));
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Adapt one remote tool descriptor into a {@link ToolContract}: build
|
|
1069
|
+
* the input schema from its JSON Schema via {@link jsonSchemaToStandard},
|
|
1070
|
+
* prefix the name, and route `execute` through a `tools/call` that
|
|
1071
|
+
* honors `ctx.signal`, unwraps the content blocks, and throws on an
|
|
1072
|
+
* `isError` result so `tool()` wraps it.
|
|
1073
|
+
*/
|
|
1074
|
+
adapt(descriptor) {
|
|
1075
|
+
const prefixedName = `${this.options.namePrefix ?? ""}${descriptor.name}`;
|
|
1076
|
+
const remoteName = descriptor.name;
|
|
1077
|
+
const timeoutMs = this.options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
1078
|
+
const input = jsonSchemaToStandard(descriptor.inputSchema);
|
|
1079
|
+
return (0, _warlock_js_ai.tool)({
|
|
1080
|
+
name: prefixedName,
|
|
1081
|
+
description: descriptor.description ?? `Invoke the remote MCP tool "${remoteName}".`,
|
|
1082
|
+
input,
|
|
1083
|
+
execute: async (args, ctx) => {
|
|
1084
|
+
const result = await this.rpc.call("tools/call", {
|
|
1085
|
+
name: remoteName,
|
|
1086
|
+
arguments: args ?? {}
|
|
1087
|
+
}, {
|
|
1088
|
+
signal: ctx?.signal,
|
|
1089
|
+
timeoutMs
|
|
1090
|
+
});
|
|
1091
|
+
if (result.isError) throw new McpTransportError(`MCP tool "${remoteName}" returned an error: ${unwrapContent(result.content)}`, {
|
|
1092
|
+
type: "protocol",
|
|
1093
|
+
method: "tools/call"
|
|
1094
|
+
});
|
|
1095
|
+
return unwrapResult(result.content);
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
close() {
|
|
1100
|
+
return this.rpc.close();
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
/**
|
|
1104
|
+
* Flatten an MCP `tools/call` result's content blocks into the value a
|
|
1105
|
+
* tool returns. Text blocks are concatenated; a single block whose text is
|
|
1106
|
+
* valid JSON is parsed so structured tool output flows back as an object
|
|
1107
|
+
* rather than a string. Non-text blocks are preserved as `type`-tagged
|
|
1108
|
+
* objects (MCP's wire `type` is kept; any inbound `kind` is normalized to
|
|
1109
|
+
* `type`).
|
|
1110
|
+
*/
|
|
1111
|
+
function unwrapResult(content) {
|
|
1112
|
+
const blocks = content ?? [];
|
|
1113
|
+
if (blocks.length === 1 && blocks[0].type === "text") return tryParseJson(blocks[0].text ?? "");
|
|
1114
|
+
return blocks.map((block) => normalizeBlock(block));
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Render content blocks to a short human string for error messages — the
|
|
1118
|
+
* concatenated text of every text block.
|
|
1119
|
+
*/
|
|
1120
|
+
function unwrapContent(content) {
|
|
1121
|
+
return (content ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join(" ").trim();
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Normalize one content block onto our `type`-only shape: translate an
|
|
1125
|
+
* inbound `kind` discriminator to `type` (and strip `kind`) so the value a
|
|
1126
|
+
* tool returns never carries MCP's `kind` vocabulary.
|
|
1127
|
+
*/
|
|
1128
|
+
function normalizeBlock(block) {
|
|
1129
|
+
const { kind, ...rest } = block;
|
|
1130
|
+
const type = block.type ?? kind ?? "unknown";
|
|
1131
|
+
return {
|
|
1132
|
+
...rest,
|
|
1133
|
+
type
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Parse a string as JSON, returning the parsed value on success or the
|
|
1138
|
+
* original string when it is not JSON — so a plain-text tool result stays
|
|
1139
|
+
* a string while a JSON tool result becomes an object.
|
|
1140
|
+
*/
|
|
1141
|
+
function tryParseJson(text) {
|
|
1142
|
+
const trimmed = text.trim();
|
|
1143
|
+
if (!trimmed) return text;
|
|
1144
|
+
const first = trimmed[0];
|
|
1145
|
+
if (first !== "{" && first !== "[") return text;
|
|
1146
|
+
try {
|
|
1147
|
+
return JSON.parse(trimmed);
|
|
1148
|
+
} catch {
|
|
1149
|
+
return text;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Connect to an external MCP server and adapt its tools as agent tools
|
|
1154
|
+
* (Direction A: server → local agent tools).
|
|
1155
|
+
*
|
|
1156
|
+
* Opens the transport lazily and exposes {@link McpClient.tools}, which on
|
|
1157
|
+
* first call runs the `initialize` handshake, lists the server's tools via
|
|
1158
|
+
* `tools/list`, and maps each into a {@link ToolContract}:
|
|
1159
|
+
* - **input schema** — the remote tool's JSON Schema is wrapped as a
|
|
1160
|
+
* Standard Schema via {@link jsonSchemaToStandard} (Ajv-backed, an
|
|
1161
|
+
* optional peer);
|
|
1162
|
+
* - **execute** — issues `tools/call` honoring `ctx.signal` and the
|
|
1163
|
+
* configured `timeoutMs`, unwraps the result content, and throws on an
|
|
1164
|
+
* `isError` result so the `tool()` wrapper surfaces it as `{ error }`;
|
|
1165
|
+
* - **name** — prefixed with `options.namePrefix` to avoid local
|
|
1166
|
+
* collisions; only tools passing `options.filter` are adapted.
|
|
1167
|
+
*
|
|
1168
|
+
* The adapted contracts are cached after the first `tools()` call, so the
|
|
1169
|
+
* handshake + list happen exactly once. The returned contracts drop
|
|
1170
|
+
* straight into `ai.agent({ tools: [...] })`.
|
|
1171
|
+
*
|
|
1172
|
+
* @param server - The transport config (`{ type: "stdio" }` /
|
|
1173
|
+
* `{ type: "http" }`). A pre-built transport client may be injected for
|
|
1174
|
+
* testing.
|
|
1175
|
+
* @param options - Prefix / filter / per-call timeout.
|
|
1176
|
+
* @returns An {@link McpClient} handle.
|
|
1177
|
+
*
|
|
1178
|
+
* @example
|
|
1179
|
+
* const github = mcp(
|
|
1180
|
+
* { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
|
|
1181
|
+
* { namePrefix: "github." },
|
|
1182
|
+
* );
|
|
1183
|
+
* const dev = ai.agent({ model, tools: [...(await github.tools())] });
|
|
1184
|
+
*/
|
|
1185
|
+
function mcp$1(server, options) {
|
|
1186
|
+
return new McpClientImpl(server, options);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
//#endregion
|
|
1190
|
+
//#region ../@warlock.js/ai-tools/src/mcp/serve.ts
|
|
1191
|
+
/** JSON-RPC version literal every outbound message carries. */
|
|
1192
|
+
const JSONRPC_VERSION = "2.0";
|
|
1193
|
+
/** Default JSON-Schema dialect emitted for each tool's `inputSchema`. */
|
|
1194
|
+
const DEFAULT_SCHEMA_TARGET = "draft-2020-12";
|
|
1195
|
+
/** Default advertised server version when the caller omits one. */
|
|
1196
|
+
const DEFAULT_VERSION = "4.4.0";
|
|
1197
|
+
/** The MCP protocol version this server advertises in `initialize`. */
|
|
1198
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
1199
|
+
/** JSON-RPC standard error codes we emit. */
|
|
1200
|
+
const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
1201
|
+
const JSON_RPC_INVALID_PARAMS = -32602;
|
|
1202
|
+
/**
|
|
1203
|
+
* Resolve the {@link McpServeSource} (either an object exposing `tools()`
|
|
1204
|
+
* or a literal `ToolContract[]`) into a flat contract array.
|
|
1205
|
+
*/
|
|
1206
|
+
function resolveTools(source) {
|
|
1207
|
+
if (Array.isArray(source)) return source;
|
|
1208
|
+
return source.tools();
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* The pure protocol core of `serve` — maps one JSON-RPC request to its
|
|
1212
|
+
* response, with no I/O. Both the stdio and http serve-transports pump
|
|
1213
|
+
* their inbound requests through this, and specs can drive it directly.
|
|
1214
|
+
*
|
|
1215
|
+
* Handles exactly the MCP slice this package serves: `initialize`,
|
|
1216
|
+
* `tools/list`, and `tools/call`. Any other method answers with a
|
|
1217
|
+
* JSON-RPC `method not found` error.
|
|
1218
|
+
*
|
|
1219
|
+
* Constructed via {@link createServeHandler}.
|
|
1220
|
+
*/
|
|
1221
|
+
var McpServeHandler = class {
|
|
1222
|
+
constructor(source, options) {
|
|
1223
|
+
this.tools = resolveTools(source);
|
|
1224
|
+
this.byName = new Map(this.tools.map((contract) => [contract.name, contract]));
|
|
1225
|
+
this.options = options;
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Dispatch one inbound JSON-RPC request to its handler and produce the
|
|
1229
|
+
* response. A handler that throws is mapped to a JSON-RPC error response
|
|
1230
|
+
* — the serve loop never crashes on a bad request.
|
|
1231
|
+
*/
|
|
1232
|
+
async handle(request) {
|
|
1233
|
+
try {
|
|
1234
|
+
switch (request.method) {
|
|
1235
|
+
case "initialize": return this.ok(request.id, this.initializeResult());
|
|
1236
|
+
case "tools/list": return this.ok(request.id, { tools: this.listTools() });
|
|
1237
|
+
case "tools/call": return this.ok(request.id, await this.callTool(request.params));
|
|
1238
|
+
default: return this.error(request.id, JSON_RPC_METHOD_NOT_FOUND, `Method "${request.method}" is not supported by this MCP server.`);
|
|
1239
|
+
}
|
|
1240
|
+
} catch (cause) {
|
|
1241
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1242
|
+
return this.error(request.id, JSON_RPC_INVALID_PARAMS, message);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/** Build the `initialize` result advertising name / version / capabilities. */
|
|
1246
|
+
initializeResult() {
|
|
1247
|
+
return {
|
|
1248
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1249
|
+
capabilities: { tools: {} },
|
|
1250
|
+
serverInfo: {
|
|
1251
|
+
name: this.options.name,
|
|
1252
|
+
version: this.options.version ?? DEFAULT_VERSION
|
|
1253
|
+
}
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Build the `tools/list` payload: one {@link McpToolDescriptor} per
|
|
1258
|
+
* contract, its `inputSchema` extracted via `extractJsonSchema` at the
|
|
1259
|
+
* configured dialect (default `draft-2020-12` — overriding
|
|
1260
|
+
* `extractJsonSchema`'s own `openai-strict` default to a neutral MCP draft).
|
|
1261
|
+
*/
|
|
1262
|
+
listTools() {
|
|
1263
|
+
const target = this.options.schemaTarget ?? DEFAULT_SCHEMA_TARGET;
|
|
1264
|
+
return this.tools.map((contract) => {
|
|
1265
|
+
const inputSchema = (0, _warlock_js_ai.extractJsonSchema)(contract.input, { target }) ?? {
|
|
1266
|
+
type: "object",
|
|
1267
|
+
properties: {}
|
|
1268
|
+
};
|
|
1269
|
+
return {
|
|
1270
|
+
name: contract.name,
|
|
1271
|
+
description: contract.description,
|
|
1272
|
+
inputSchema
|
|
1273
|
+
};
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Route a `tools/call` to the named contract's `invoke()` and map the
|
|
1278
|
+
* never-throwing {@link import("@warlock.js/ai").ToolInvokeResult}: `data`
|
|
1279
|
+
* → a text content block, `error` → an `isError: true` result. An unknown
|
|
1280
|
+
* tool name throws (mapped to a JSON-RPC error by {@link handle}).
|
|
1281
|
+
*/
|
|
1282
|
+
async callTool(params) {
|
|
1283
|
+
const { name, args } = readCallParams(params);
|
|
1284
|
+
const contract = this.byName.get(name);
|
|
1285
|
+
if (!contract) throw new McpTransportError(`Unknown tool "${name}".`, {
|
|
1286
|
+
type: "protocol",
|
|
1287
|
+
method: "tools/call"
|
|
1288
|
+
});
|
|
1289
|
+
const result = await contract.invoke(args);
|
|
1290
|
+
if (result.error) return {
|
|
1291
|
+
content: [{
|
|
1292
|
+
type: "text",
|
|
1293
|
+
text: result.error.message
|
|
1294
|
+
}],
|
|
1295
|
+
isError: true
|
|
1296
|
+
};
|
|
1297
|
+
return {
|
|
1298
|
+
content: [{
|
|
1299
|
+
type: "text",
|
|
1300
|
+
text: serializeData(result.data)
|
|
1301
|
+
}],
|
|
1302
|
+
isError: false
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
/** Build a JSON-RPC success response. */
|
|
1306
|
+
ok(id, result) {
|
|
1307
|
+
return {
|
|
1308
|
+
jsonrpc: JSONRPC_VERSION,
|
|
1309
|
+
id,
|
|
1310
|
+
result
|
|
1311
|
+
};
|
|
1312
|
+
}
|
|
1313
|
+
/** Build a JSON-RPC error response. */
|
|
1314
|
+
error(id, code, message) {
|
|
1315
|
+
return {
|
|
1316
|
+
jsonrpc: JSONRPC_VERSION,
|
|
1317
|
+
id,
|
|
1318
|
+
error: {
|
|
1319
|
+
code,
|
|
1320
|
+
message
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
/**
|
|
1326
|
+
* Read and validate the `tools/call` params into `{ name, args }`. Throws
|
|
1327
|
+
* a typed {@link McpTransportError} when `name` is missing — mapped to a
|
|
1328
|
+
* JSON-RPC `invalid params` error by the handler.
|
|
1329
|
+
*/
|
|
1330
|
+
function readCallParams(params) {
|
|
1331
|
+
if (typeof params !== "object" || params === null) throw new McpTransportError("tools/call params must be an object.", {
|
|
1332
|
+
type: "protocol",
|
|
1333
|
+
method: "tools/call"
|
|
1334
|
+
});
|
|
1335
|
+
const record = params;
|
|
1336
|
+
if (typeof record.name !== "string") throw new McpTransportError("tools/call requires a string `name`.", {
|
|
1337
|
+
type: "protocol",
|
|
1338
|
+
method: "tools/call"
|
|
1339
|
+
});
|
|
1340
|
+
return {
|
|
1341
|
+
name: record.name,
|
|
1342
|
+
args: record.arguments ?? {}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Serialize a tool's `data` for an MCP text content block — a string is
|
|
1347
|
+
* passed verbatim, everything else is JSON-stringified so structured
|
|
1348
|
+
* output crosses the wire as text the consuming client can re-parse.
|
|
1349
|
+
*/
|
|
1350
|
+
function serializeData(data) {
|
|
1351
|
+
if (typeof data === "string") return data;
|
|
1352
|
+
if (data === void 0) return "";
|
|
1353
|
+
return JSON.stringify(data);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Build the pure protocol handler for a serve source. Exposed (alongside
|
|
1357
|
+
* {@link serve}) so callers and tests can drive the MCP protocol without an
|
|
1358
|
+
* actual transport — feed it a JSON-RPC request, get the response.
|
|
1359
|
+
*
|
|
1360
|
+
* @param source - The tools to expose (an object with `tools()` or a literal array).
|
|
1361
|
+
* @param options - Serve options (name / version / schema target).
|
|
1362
|
+
* @returns An object whose `handle(request)` maps a request to a response.
|
|
1363
|
+
*/
|
|
1364
|
+
function createServeHandler(source, options) {
|
|
1365
|
+
return new McpServeHandler(source, options);
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The internal {@link McpServer} — owns a {@link McpServeHandler} and a
|
|
1369
|
+
* transport pump. For `stdio` it reads newline-delimited JSON-RPC requests
|
|
1370
|
+
* from `process.stdin` and writes responses to `process.stdout`; the
|
|
1371
|
+
* `http` transport is accepted but listening is deferred to the host
|
|
1372
|
+
* (a serve-over-HTTP needs a server the caller owns).
|
|
1373
|
+
*
|
|
1374
|
+
* Constructed via {@link serve}; the class itself is internal.
|
|
1375
|
+
*/
|
|
1376
|
+
var McpServerImpl = class {
|
|
1377
|
+
constructor(source, options) {
|
|
1378
|
+
this.running = false;
|
|
1379
|
+
this.handler = new McpServeHandler(source, options);
|
|
1380
|
+
this.options = options;
|
|
1381
|
+
}
|
|
1382
|
+
async start() {
|
|
1383
|
+
if (this.running) return;
|
|
1384
|
+
if ((this.options.transport ?? { type: "stdio" }).type !== "stdio") throw new McpTransportError("serve() over http requires a host-provided server; only stdio is auto-pumped.", { type: "connect" });
|
|
1385
|
+
this.running = true;
|
|
1386
|
+
this.reader = (0, node_readline.createInterface)({ input: process.stdin });
|
|
1387
|
+
this.reader.on("line", (line) => {
|
|
1388
|
+
this.onLine(line);
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* Parse one stdin line as a JSON-RPC request, dispatch it through the
|
|
1393
|
+
* handler, and write the response as a single line to stdout. Non-JSON
|
|
1394
|
+
* lines and notifications (no `id`) are ignored.
|
|
1395
|
+
*/
|
|
1396
|
+
async onLine(line) {
|
|
1397
|
+
const trimmed = line.trim();
|
|
1398
|
+
if (!trimmed) return;
|
|
1399
|
+
let request;
|
|
1400
|
+
try {
|
|
1401
|
+
request = JSON.parse(trimmed);
|
|
1402
|
+
} catch {
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
if (request.id === void 0 || request.id === null) return;
|
|
1406
|
+
const response = await this.handler.handle(request);
|
|
1407
|
+
process.stdout.write(`${JSON.stringify(response)}\n`);
|
|
1408
|
+
}
|
|
1409
|
+
async stop() {
|
|
1410
|
+
this.running = false;
|
|
1411
|
+
this.reader?.close();
|
|
1412
|
+
this.reader = void 0;
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
/**
|
|
1416
|
+
* Expose a built agent / supervisor / orchestrator (or a raw
|
|
1417
|
+
* `ToolContract[]`) AS an MCP server (Direction B: local primitive → MCP
|
|
1418
|
+
* server other clients consume).
|
|
1419
|
+
*
|
|
1420
|
+
* Enumerates `source.tools()` (or the literal array) once at construction.
|
|
1421
|
+
* `tools/list` answers with each tool's `inputSchema` extracted via
|
|
1422
|
+
* `extractJsonSchema` at the configured `schemaTarget` (default
|
|
1423
|
+
* `draft-2020-12`). `tools/call` routes to the named contract's
|
|
1424
|
+
* `invoke()` and maps the never-throwing result — `data` becomes a text
|
|
1425
|
+
* content block, `error` becomes an `isError: true` result — so a failing
|
|
1426
|
+
* tool surfaces as a normal MCP tool error rather than crashing the server.
|
|
1427
|
+
*
|
|
1428
|
+
* The default transport is `stdio`, pumped over `process.stdin` /
|
|
1429
|
+
* `process.stdout`. Serving over HTTP is left to a host-owned server;
|
|
1430
|
+
* `start()` rejects an `http` transport (the protocol core is available
|
|
1431
|
+
* via {@link createServeHandler} for a caller's own HTTP wiring).
|
|
1432
|
+
*
|
|
1433
|
+
* @param source - The tools to expose.
|
|
1434
|
+
* @param options - Server name, version, transport, and schema dialect.
|
|
1435
|
+
* @returns An {@link McpServer} with `start()` / `stop()`.
|
|
1436
|
+
*
|
|
1437
|
+
* @example
|
|
1438
|
+
* serve(
|
|
1439
|
+
* { tools: () => ws.allTools() },
|
|
1440
|
+
* { name: "warlock-workspace", transport: { type: "stdio" } },
|
|
1441
|
+
* ).start();
|
|
1442
|
+
*/
|
|
1443
|
+
function serve(source, options) {
|
|
1444
|
+
return new McpServerImpl(source, options);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
//#endregion
|
|
1448
|
+
//#region ../@warlock.js/ai-tools/src/mcp/index.ts
|
|
1449
|
+
/**
|
|
1450
|
+
* The `ai.mcp` factory value: the client factory with `.serve` attached.
|
|
1451
|
+
* `Object.assign` keeps `mcp` callable (Direction A) while widening it with
|
|
1452
|
+
* the `serve` member (Direction B) — one object, both directions. The
|
|
1453
|
+
* `declare module "@warlock.js/ai"` augmentation and the runtime
|
|
1454
|
+
* registration (`ai.mcp = mcp`) live in `../register`, so this barrel is a
|
|
1455
|
+
* pure value/type module the registrar consumes.
|
|
1456
|
+
*/
|
|
1457
|
+
const mcp = Object.assign((server, options) => mcp$1(server, options), { serve });
|
|
1458
|
+
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region ../@warlock.js/ai-tools/src/utility/schema.ts
|
|
1461
|
+
/**
|
|
1462
|
+
* Tiny, dependency-free [Standard Schema](https://standardschema.dev)
|
|
1463
|
+
* builders for the utility tools' input validation. This package pins
|
|
1464
|
+
* only `@warlock.js/ai` as a runtime peer, so rather than pull in a
|
|
1465
|
+
* schema library we hand-roll the few field shapes the calculator and
|
|
1466
|
+
* date-time tools need — the same approach `@warlock.js/ai`'s own
|
|
1467
|
+
* `tool()` tests use. Each builder returns a {@link StandardSchemaV1},
|
|
1468
|
+
* which is what `tool({ input })` validates against before calling
|
|
1469
|
+
* `execute`.
|
|
1470
|
+
*
|
|
1471
|
+
* These intentionally cover only the primitive cases the UTILITY tools
|
|
1472
|
+
* require (`string`, `optional string`, `optional number`, and a
|
|
1473
|
+
* `literal union` enum). They are not a general-purpose validator.
|
|
1474
|
+
*/
|
|
1475
|
+
/** The vendor tag stamped on every issue these builders produce. */
|
|
1476
|
+
const VENDOR$1 = "ai-tools";
|
|
1477
|
+
/** Required string field — rejects anything that is not a string. */
|
|
1478
|
+
function stringField$1() {
|
|
1479
|
+
return (value, key) => {
|
|
1480
|
+
if (typeof value === "string") return { value };
|
|
1481
|
+
return { issues: [{
|
|
1482
|
+
message: `"${key}" must be a string`,
|
|
1483
|
+
path: [key]
|
|
1484
|
+
}] };
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Optional string field — accepts `undefined` (the property absent or
|
|
1489
|
+
* explicitly undefined) or a string, and rejects every other type.
|
|
1490
|
+
*/
|
|
1491
|
+
function optionalStringField() {
|
|
1492
|
+
return (value, key) => {
|
|
1493
|
+
if (value === void 0) return { value: void 0 };
|
|
1494
|
+
if (typeof value === "string") return { value };
|
|
1495
|
+
return { issues: [{
|
|
1496
|
+
message: `"${key}" must be a string when provided`,
|
|
1497
|
+
path: [key]
|
|
1498
|
+
}] };
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Optional finite-number field — accepts `undefined` or a finite number,
|
|
1503
|
+
* rejecting `NaN`/`Infinity` and non-number types.
|
|
1504
|
+
*/
|
|
1505
|
+
function optionalNumberField$1() {
|
|
1506
|
+
return (value, key) => {
|
|
1507
|
+
if (value === void 0) return { value: void 0 };
|
|
1508
|
+
if (typeof value === "number" && Number.isFinite(value)) return { value };
|
|
1509
|
+
return { issues: [{
|
|
1510
|
+
message: `"${key}" must be a finite number when provided`,
|
|
1511
|
+
path: [key]
|
|
1512
|
+
}] };
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Required literal-union field — accepts only one of `allowed`, rejecting
|
|
1517
|
+
* everything else. Used for discriminators like the date-time `op`.
|
|
1518
|
+
*
|
|
1519
|
+
* @param allowed - The closed set of permitted string literals.
|
|
1520
|
+
*/
|
|
1521
|
+
function literalField(allowed) {
|
|
1522
|
+
return (value, key) => {
|
|
1523
|
+
if (typeof value === "string" && allowed.includes(value)) return { value };
|
|
1524
|
+
return { issues: [{
|
|
1525
|
+
message: `"${key}" must be one of: ${allowed.join(", ")}`,
|
|
1526
|
+
path: [key]
|
|
1527
|
+
}] };
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Build a {@link StandardSchemaV1} for a flat object whose every property
|
|
1532
|
+
* is validated by a {@link FieldValidator}. The input must be a non-null
|
|
1533
|
+
* object; each declared field is validated and the (possibly coerced)
|
|
1534
|
+
* values are collected into the typed result. All field issues are merged
|
|
1535
|
+
* so the caller sees every problem at once.
|
|
1536
|
+
*
|
|
1537
|
+
* `T` is constrained to `object` rather than `Record<string, unknown>` so
|
|
1538
|
+
* the tool IO `interface`s (which carry no implicit string index
|
|
1539
|
+
* signature) satisfy it directly — only the declared keys in `shape` are
|
|
1540
|
+
* ever read, so a string index signature is never required.
|
|
1541
|
+
*
|
|
1542
|
+
* @example
|
|
1543
|
+
* const schema = objectSchema<{ expression: string }>({
|
|
1544
|
+
* expression: stringField(),
|
|
1545
|
+
* });
|
|
1546
|
+
*/
|
|
1547
|
+
function objectSchema$1(shape) {
|
|
1548
|
+
return { "~standard": {
|
|
1549
|
+
version: 1,
|
|
1550
|
+
vendor: VENDOR$1,
|
|
1551
|
+
validate(input) {
|
|
1552
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
|
|
1553
|
+
const source = input;
|
|
1554
|
+
const issues = [];
|
|
1555
|
+
const result = {};
|
|
1556
|
+
for (const key of Object.keys(shape)) {
|
|
1557
|
+
const field = shape[key];
|
|
1558
|
+
const outcome = field(source[key], key);
|
|
1559
|
+
if ("issues" in outcome) {
|
|
1560
|
+
issues.push(...outcome.issues);
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
if (outcome.value !== void 0) result[key] = outcome.value;
|
|
1564
|
+
}
|
|
1565
|
+
if (issues.length > 0) return { issues };
|
|
1566
|
+
return { value: result };
|
|
1567
|
+
}
|
|
1568
|
+
} };
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
//#endregion
|
|
1572
|
+
//#region ../@warlock.js/ai-tools/src/utility/calculator.ts
|
|
1573
|
+
/** Default tool name exposed to the LLM. */
|
|
1574
|
+
const DEFAULT_NAME$3 = "calculator";
|
|
1575
|
+
/** Input schema for the `calculator` tool: a single `expression` string. */
|
|
1576
|
+
const inputSchema$3 = objectSchema$1({ expression: stringField$1() });
|
|
1577
|
+
/** The closed set of supported binary operators. */
|
|
1578
|
+
const OPERATORS = {
|
|
1579
|
+
"+": {
|
|
1580
|
+
precedence: 1,
|
|
1581
|
+
associativity: "left",
|
|
1582
|
+
apply: (a, b) => a + b
|
|
1583
|
+
},
|
|
1584
|
+
"-": {
|
|
1585
|
+
precedence: 1,
|
|
1586
|
+
associativity: "left",
|
|
1587
|
+
apply: (a, b) => a - b
|
|
1588
|
+
},
|
|
1589
|
+
"*": {
|
|
1590
|
+
precedence: 2,
|
|
1591
|
+
associativity: "left",
|
|
1592
|
+
apply: (a, b) => a * b
|
|
1593
|
+
},
|
|
1594
|
+
"/": {
|
|
1595
|
+
precedence: 2,
|
|
1596
|
+
associativity: "left",
|
|
1597
|
+
apply: (a, b) => {
|
|
1598
|
+
if (b === 0) throw new CalculatorError("Division by zero.", { type: "divide-by-zero" });
|
|
1599
|
+
return a / b;
|
|
1600
|
+
}
|
|
1601
|
+
},
|
|
1602
|
+
"%": {
|
|
1603
|
+
precedence: 2,
|
|
1604
|
+
associativity: "left",
|
|
1605
|
+
apply: (a, b) => {
|
|
1606
|
+
if (b === 0) throw new CalculatorError("Modulo by zero.", { type: "divide-by-zero" });
|
|
1607
|
+
return a % b;
|
|
1608
|
+
}
|
|
1609
|
+
},
|
|
1610
|
+
"^": {
|
|
1611
|
+
precedence: 3,
|
|
1612
|
+
associativity: "right",
|
|
1613
|
+
apply: (a, b) => a ** b
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
/**
|
|
1617
|
+
* Tokenize an arithmetic expression into {@link Token}s. Recognizes
|
|
1618
|
+
* decimal and scientific-notation numbers (`3`, `4.5`, `1e3`, `2.5E-2`),
|
|
1619
|
+
* the operators in {@link OPERATORS}, and parentheses; whitespace is
|
|
1620
|
+
* skipped. Any other character is a syntax error — there is no path to
|
|
1621
|
+
* an identifier, function call, or property access, so nothing
|
|
1622
|
+
* code-like can be smuggled in.
|
|
1623
|
+
*
|
|
1624
|
+
* @throws CalculatorError `type: "syntax"` on an unrecognized character
|
|
1625
|
+
* or a malformed number.
|
|
1626
|
+
*/
|
|
1627
|
+
function tokenize(expression) {
|
|
1628
|
+
const tokens = [];
|
|
1629
|
+
let index = 0;
|
|
1630
|
+
while (index < expression.length) {
|
|
1631
|
+
const char = expression[index];
|
|
1632
|
+
if (char === " " || char === " " || char === "\n" || char === "\r") {
|
|
1633
|
+
index += 1;
|
|
1634
|
+
continue;
|
|
1635
|
+
}
|
|
1636
|
+
if (char === "(" || char === ")") {
|
|
1637
|
+
tokens.push({
|
|
1638
|
+
type: "paren",
|
|
1639
|
+
value: char
|
|
1640
|
+
});
|
|
1641
|
+
index += 1;
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
if (char in OPERATORS) {
|
|
1645
|
+
tokens.push({
|
|
1646
|
+
type: "operator",
|
|
1647
|
+
value: char
|
|
1648
|
+
});
|
|
1649
|
+
index += 1;
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
if (isDigit(char) || char === ".") {
|
|
1653
|
+
const { value, nextIndex } = readNumber(expression, index);
|
|
1654
|
+
tokens.push({
|
|
1655
|
+
type: "number",
|
|
1656
|
+
value
|
|
1657
|
+
});
|
|
1658
|
+
index = nextIndex;
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
throw new CalculatorError(`Unexpected character "${char}" at position ${index}. Only numbers, parentheses, and the operators + - * / % ^ are allowed.`, { type: "syntax" });
|
|
1662
|
+
}
|
|
1663
|
+
return tokens;
|
|
1664
|
+
}
|
|
1665
|
+
/** True for an ASCII digit `0`–`9`. */
|
|
1666
|
+
function isDigit(char) {
|
|
1667
|
+
return char >= "0" && char <= "9";
|
|
1668
|
+
}
|
|
1669
|
+
/**
|
|
1670
|
+
* Read a single number literal starting at `start`. Consumes an optional
|
|
1671
|
+
* integer part, optional fraction, and optional exponent
|
|
1672
|
+
* (`e`/`E` with an optional sign). Returns the parsed value and the index
|
|
1673
|
+
* just past the literal.
|
|
1674
|
+
*
|
|
1675
|
+
* @throws CalculatorError `type: "syntax"` if the consumed run is not a
|
|
1676
|
+
* valid finite number (e.g. a lone `.` or `1e` with no exponent).
|
|
1677
|
+
*/
|
|
1678
|
+
function readNumber(expression, start) {
|
|
1679
|
+
let index = start;
|
|
1680
|
+
while (index < expression.length && isDigit(expression[index])) index += 1;
|
|
1681
|
+
if (expression[index] === ".") {
|
|
1682
|
+
index += 1;
|
|
1683
|
+
while (index < expression.length && isDigit(expression[index])) index += 1;
|
|
1684
|
+
}
|
|
1685
|
+
if (expression[index] === "e" || expression[index] === "E") {
|
|
1686
|
+
index += 1;
|
|
1687
|
+
if (expression[index] === "+" || expression[index] === "-") index += 1;
|
|
1688
|
+
while (index < expression.length && isDigit(expression[index])) index += 1;
|
|
1689
|
+
}
|
|
1690
|
+
const literal = expression.slice(start, index);
|
|
1691
|
+
const value = Number(literal);
|
|
1692
|
+
if (!Number.isFinite(value)) throw new CalculatorError(`Invalid number literal "${literal}".`, { type: "syntax" });
|
|
1693
|
+
return {
|
|
1694
|
+
value,
|
|
1695
|
+
nextIndex: index
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Evaluate a token stream with a single left-to-right pass that resolves
|
|
1700
|
+
* unary signs, then a shunting-yard conversion that interleaves operator
|
|
1701
|
+
* application — so the result is produced without ever building an AST or
|
|
1702
|
+
* calling `eval`/`Function`.
|
|
1703
|
+
*
|
|
1704
|
+
* Unary `+`/`-` are detected positionally: a `+`/`-` is unary when it
|
|
1705
|
+
* starts the expression or directly follows another operator or an
|
|
1706
|
+
* opening paren. A unary `-` folds into the following number literal
|
|
1707
|
+
* (and a unary `+` is a no-op), which keeps the operator stack purely
|
|
1708
|
+
* binary.
|
|
1709
|
+
*
|
|
1710
|
+
* @throws CalculatorError on malformed structure (`type: "syntax"`),
|
|
1711
|
+
* division/modulo by zero (`type: "divide-by-zero"`), or a non-finite
|
|
1712
|
+
* result (`type: "overflow"`).
|
|
1713
|
+
*/
|
|
1714
|
+
function evaluate(tokens) {
|
|
1715
|
+
const values = [];
|
|
1716
|
+
const operators = [];
|
|
1717
|
+
/** Pop the top operator and apply it to the top two values. */
|
|
1718
|
+
const applyTop = () => {
|
|
1719
|
+
const operator = operators.pop();
|
|
1720
|
+
if (operator === void 0) throw new CalculatorError("Malformed expression.", { type: "syntax" });
|
|
1721
|
+
const right = values.pop();
|
|
1722
|
+
const left = values.pop();
|
|
1723
|
+
if (left === void 0 || right === void 0) throw new CalculatorError(`Operator "${operator}" is missing an operand.`, { type: "syntax" });
|
|
1724
|
+
values.push(OPERATORS[operator].apply(left, right));
|
|
1725
|
+
};
|
|
1726
|
+
let expectOperand = true;
|
|
1727
|
+
let pendingSign = 1;
|
|
1728
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
1729
|
+
const token = tokens[i];
|
|
1730
|
+
if (token.type === "number") {
|
|
1731
|
+
if (!expectOperand) throw new CalculatorError("Unexpected number — a number cannot directly follow another value.", { type: "syntax" });
|
|
1732
|
+
values.push(pendingSign * token.value);
|
|
1733
|
+
pendingSign = 1;
|
|
1734
|
+
expectOperand = false;
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
if (token.type === "paren") {
|
|
1738
|
+
if (token.value === "(") {
|
|
1739
|
+
if (!expectOperand) throw new CalculatorError("Unexpected \"(\" — it cannot directly follow a value.", { type: "syntax" });
|
|
1740
|
+
if (pendingSign === -1) {
|
|
1741
|
+
values.push(-1);
|
|
1742
|
+
operators.push("*");
|
|
1743
|
+
pendingSign = 1;
|
|
1744
|
+
}
|
|
1745
|
+
operators.push("(");
|
|
1746
|
+
expectOperand = true;
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
if (expectOperand) throw new CalculatorError("Unexpected \")\" — an operand was expected.", { type: "syntax" });
|
|
1750
|
+
let foundOpen = false;
|
|
1751
|
+
while (operators.length > 0) {
|
|
1752
|
+
if (operators[operators.length - 1] === "(") {
|
|
1753
|
+
operators.pop();
|
|
1754
|
+
foundOpen = true;
|
|
1755
|
+
break;
|
|
1756
|
+
}
|
|
1757
|
+
applyTop();
|
|
1758
|
+
}
|
|
1759
|
+
if (!foundOpen) throw new CalculatorError("Unbalanced parentheses.", { type: "syntax" });
|
|
1760
|
+
expectOperand = false;
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (expectOperand) {
|
|
1764
|
+
if (token.value === "-") {
|
|
1765
|
+
pendingSign = -pendingSign;
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
if (token.value === "+") continue;
|
|
1769
|
+
throw new CalculatorError(`Operator "${token.value}" has no left-hand operand.`, { type: "syntax" });
|
|
1770
|
+
}
|
|
1771
|
+
const incoming = OPERATORS[token.value];
|
|
1772
|
+
while (operators.length > 0) {
|
|
1773
|
+
const top = operators[operators.length - 1];
|
|
1774
|
+
if (top === "(") break;
|
|
1775
|
+
const topSpec = OPERATORS[top];
|
|
1776
|
+
const higher = topSpec.precedence > incoming.precedence;
|
|
1777
|
+
const equalLeft = topSpec.precedence === incoming.precedence && incoming.associativity === "left";
|
|
1778
|
+
if (higher || equalLeft) {
|
|
1779
|
+
applyTop();
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
break;
|
|
1783
|
+
}
|
|
1784
|
+
operators.push(token.value);
|
|
1785
|
+
expectOperand = true;
|
|
1786
|
+
}
|
|
1787
|
+
if (expectOperand) throw new CalculatorError("Expression ends with an operator or is empty.", { type: "syntax" });
|
|
1788
|
+
while (operators.length > 0) {
|
|
1789
|
+
if (operators[operators.length - 1] === "(") throw new CalculatorError("Unbalanced parentheses.", { type: "syntax" });
|
|
1790
|
+
applyTop();
|
|
1791
|
+
}
|
|
1792
|
+
const result = values.pop();
|
|
1793
|
+
if (result === void 0 || values.length > 0) throw new CalculatorError("Malformed expression.", { type: "syntax" });
|
|
1794
|
+
if (!Number.isFinite(result)) throw new CalculatorError("Result is not a finite number.", { type: "overflow" });
|
|
1795
|
+
return result;
|
|
1796
|
+
}
|
|
1797
|
+
/**
|
|
1798
|
+
* Build the `calculator` tool — a SAFE arithmetic evaluator the agent can
|
|
1799
|
+
* call to compute a numeric expression. It supports `+ - * / % ^`, unary
|
|
1800
|
+
* signs, parentheses, and decimal/scientific-notation literals, with the
|
|
1801
|
+
* usual precedence (`^` highest and right-associative, then `* / %`, then
|
|
1802
|
+
* `+ -`).
|
|
1803
|
+
*
|
|
1804
|
+
* **Safety.** The expression is tokenized and evaluated with a
|
|
1805
|
+
* shunting-yard pass — it NEVER calls `eval` or `new Function`. The
|
|
1806
|
+
* lexer only recognizes numbers, parentheses, and the fixed operator set,
|
|
1807
|
+
* so there is no path to an identifier, function call, or property
|
|
1808
|
+
* access; any other character is a syntax error returned as data.
|
|
1809
|
+
*
|
|
1810
|
+
* **Errors flow as data.** A malformed expression, division/modulo by
|
|
1811
|
+
* zero, or a non-finite result throws a {@link CalculatorError} inside
|
|
1812
|
+
* the handler; `tool()` catches it and surfaces it in the returned
|
|
1813
|
+
* `{ error }` field (the LLM-visible message is preserved), so the agent
|
|
1814
|
+
* reads the failure and self-corrects instead of crashing.
|
|
1815
|
+
*
|
|
1816
|
+
* @param options - Optional overrides; `name` renames the LLM-visible tool.
|
|
1817
|
+
* @returns A `ToolContract<{ expression }, { result }>` ready to drop into `tools: []`.
|
|
1818
|
+
*
|
|
1819
|
+
* @example
|
|
1820
|
+
* const calc = calculatorTool();
|
|
1821
|
+
* const { data } = await calc.invoke({ expression: "(3 + 4) * 2" });
|
|
1822
|
+
* console.log(data?.result); // 14
|
|
1823
|
+
*/
|
|
1824
|
+
function calculatorTool(options) {
|
|
1825
|
+
return (0, _warlock_js_ai.tool)({
|
|
1826
|
+
name: options?.name ?? DEFAULT_NAME$3,
|
|
1827
|
+
description: "Evaluate an arithmetic expression and return the numeric result. Supports + - * / % ^, parentheses, unary +/-, and decimal or scientific-notation numbers. Does not support variables or functions — pass a literal expression like \"(3 + 4) * 2\".",
|
|
1828
|
+
input: inputSchema$3,
|
|
1829
|
+
async execute(input) {
|
|
1830
|
+
const tokens = tokenize(input.expression);
|
|
1831
|
+
if (tokens.length === 0) throw new CalculatorError("Expression is empty.", { type: "syntax" });
|
|
1832
|
+
return { result: evaluate(tokens) };
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
//#endregion
|
|
1838
|
+
//#region ../@warlock.js/ai-tools/src/utility/date-time.ts
|
|
1839
|
+
/** Default tool name exposed to the LLM. */
|
|
1840
|
+
const DEFAULT_NAME$2 = "date_time";
|
|
1841
|
+
/** Input schema for the `date_time` tool. */
|
|
1842
|
+
const inputSchema$2 = objectSchema$1({
|
|
1843
|
+
op: literalField([
|
|
1844
|
+
"now",
|
|
1845
|
+
"add",
|
|
1846
|
+
"diff",
|
|
1847
|
+
"format"
|
|
1848
|
+
]),
|
|
1849
|
+
iso: optionalStringField(),
|
|
1850
|
+
from: optionalStringField(),
|
|
1851
|
+
to: optionalStringField(),
|
|
1852
|
+
amount: optionalNumberField$1(),
|
|
1853
|
+
unit: optionalStringField(),
|
|
1854
|
+
timeZone: optionalStringField(),
|
|
1855
|
+
format: optionalStringField()
|
|
1856
|
+
});
|
|
1857
|
+
/**
|
|
1858
|
+
* The time units `add` and `diff` understand, each as its length in
|
|
1859
|
+
* milliseconds. Calendar-unaware on purpose: a "month" / "year" has no
|
|
1860
|
+
* fixed millisecond length, so they are deliberately excluded — adding
|
|
1861
|
+
* `30 days` is unambiguous in a way adding `1 month` is not.
|
|
1862
|
+
*/
|
|
1863
|
+
const UNIT_MS = {
|
|
1864
|
+
milliseconds: 1,
|
|
1865
|
+
seconds: 1e3,
|
|
1866
|
+
minutes: 6e4,
|
|
1867
|
+
hours: 36e5,
|
|
1868
|
+
days: 864e5,
|
|
1869
|
+
weeks: 6048e5
|
|
1870
|
+
};
|
|
1871
|
+
/** Singular aliases mapped to their canonical plural unit key. */
|
|
1872
|
+
const UNIT_ALIASES = {
|
|
1873
|
+
millisecond: "milliseconds",
|
|
1874
|
+
ms: "milliseconds",
|
|
1875
|
+
second: "seconds",
|
|
1876
|
+
sec: "seconds",
|
|
1877
|
+
s: "seconds",
|
|
1878
|
+
minute: "minutes",
|
|
1879
|
+
min: "minutes",
|
|
1880
|
+
m: "minutes",
|
|
1881
|
+
hour: "hours",
|
|
1882
|
+
hr: "hours",
|
|
1883
|
+
h: "hours",
|
|
1884
|
+
day: "days",
|
|
1885
|
+
d: "days",
|
|
1886
|
+
week: "weeks",
|
|
1887
|
+
w: "weeks"
|
|
1888
|
+
};
|
|
1889
|
+
/** Resolve a (possibly aliased / singular) unit token to its ms length. */
|
|
1890
|
+
function unitToMs(unit) {
|
|
1891
|
+
const ms = UNIT_MS[UNIT_ALIASES[unit] ?? unit];
|
|
1892
|
+
if (ms === void 0) throw new DateTimeError(`Unknown unit "${unit}". Supported units: ${Object.keys(UNIT_MS).join(", ")}.`, { type: "invalid-unit" });
|
|
1893
|
+
return ms;
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Parse an ISO-8601 instant into a {@link Date}, or throw a typed
|
|
1897
|
+
* {@link DateTimeError} when the string is missing or unparseable.
|
|
1898
|
+
*
|
|
1899
|
+
* @param iso - The ISO string from the model (may be undefined).
|
|
1900
|
+
* @param field - The input field name, for the error message.
|
|
1901
|
+
*/
|
|
1902
|
+
function parseIso(iso, field) {
|
|
1903
|
+
if (iso === void 0) throw new DateTimeError(`"${field}" is required for this operation.`, { type: "invalid-input" });
|
|
1904
|
+
const date = new Date(iso);
|
|
1905
|
+
if (Number.isNaN(date.getTime())) throw new DateTimeError(`"${field}" is not a valid ISO-8601 instant: "${iso}".`, { type: "invalid-input" });
|
|
1906
|
+
return date;
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* Render a {@link Date} in a target time zone using `Intl`. The
|
|
1910
|
+
* `"iso"` format (the default) returns the instant's UTC ISO string;
|
|
1911
|
+
* any other `format` value is treated as an `Intl.DateTimeFormat`
|
|
1912
|
+
* locale-style rendering in the given `timeZone`.
|
|
1913
|
+
*
|
|
1914
|
+
* @throws DateTimeError `type: "invalid-time-zone"` when `timeZone` is
|
|
1915
|
+
* not a recognized IANA zone.
|
|
1916
|
+
*/
|
|
1917
|
+
function render$1(date, format, timeZone) {
|
|
1918
|
+
if (format === void 0 || format === "iso") {
|
|
1919
|
+
if (timeZone !== void 0) assertTimeZone(timeZone);
|
|
1920
|
+
return date.toISOString();
|
|
1921
|
+
}
|
|
1922
|
+
try {
|
|
1923
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
1924
|
+
timeZone,
|
|
1925
|
+
dateStyle: format === "date" ? "medium" : void 0,
|
|
1926
|
+
timeStyle: format === "time" ? "medium" : void 0,
|
|
1927
|
+
...format === "datetime" ? {
|
|
1928
|
+
dateStyle: "medium",
|
|
1929
|
+
timeStyle: "medium"
|
|
1930
|
+
} : {}
|
|
1931
|
+
}).format(date);
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
throw new DateTimeError(`Could not render with format "${format}"${timeZone ? ` in time zone "${timeZone}"` : ""}.`, {
|
|
1934
|
+
type: "invalid-time-zone",
|
|
1935
|
+
cause: error
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Validate an IANA time zone by attempting to construct a formatter for
|
|
1941
|
+
* it; an unrecognized zone makes `Intl` throw a `RangeError`.
|
|
1942
|
+
*
|
|
1943
|
+
* @throws DateTimeError `type: "invalid-time-zone"` for an unknown zone.
|
|
1944
|
+
*/
|
|
1945
|
+
function assertTimeZone(timeZone) {
|
|
1946
|
+
try {
|
|
1947
|
+
new Intl.DateTimeFormat("en-US", { timeZone });
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
throw new DateTimeError(`Unknown time zone "${timeZone}".`, {
|
|
1950
|
+
type: "invalid-time-zone",
|
|
1951
|
+
cause: error
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Build the `date_time` tool — a small clock/calendar utility the agent
|
|
1957
|
+
* can call to read the current instant, shift an instant, measure the
|
|
1958
|
+
* gap between two instants, or render one. The `op` discriminator selects
|
|
1959
|
+
* the operation:
|
|
1960
|
+
*
|
|
1961
|
+
* - **`now`** — the current instant, rendered per `format` / `timeZone`
|
|
1962
|
+
* (defaults to a UTC ISO string). Reads the injectable {@link Clock}.
|
|
1963
|
+
* - **`add`** — `iso` shifted by `amount` of `unit` (e.g.
|
|
1964
|
+
* `+3 days`); a negative `amount` shifts backward.
|
|
1965
|
+
* - **`diff`** — the signed difference `to − iso`, expressed in `unit`.
|
|
1966
|
+
* - **`format`** — `iso` rendered per `format` / `timeZone`.
|
|
1967
|
+
*
|
|
1968
|
+
* Units are millisecond-based (`milliseconds`…`weeks`, plus common
|
|
1969
|
+
* aliases); calendar-relative `month`/`year` are intentionally
|
|
1970
|
+
* unsupported because they have no fixed length.
|
|
1971
|
+
*
|
|
1972
|
+
* **Deterministic in tests.** The clock backing `now` is injectable via
|
|
1973
|
+
* `deps.clock`; production defaults to `Date.now`.
|
|
1974
|
+
*
|
|
1975
|
+
* **Errors flow as data.** A missing/invalid field, an unknown unit, or
|
|
1976
|
+
* an unrecognized time zone throws a {@link DateTimeError} inside the
|
|
1977
|
+
* handler; `tool()` surfaces it in `{ error }` so the agent self-corrects.
|
|
1978
|
+
*
|
|
1979
|
+
* @param options - Optional overrides; `name` renames the tool,
|
|
1980
|
+
* `defaultTimeZone` applies when a call omits `timeZone`.
|
|
1981
|
+
* @param deps - Injectable dependencies (the {@link Clock}); defaults to real time.
|
|
1982
|
+
* @returns A `ToolContract<DateTimeInput, { value }>` ready for `tools: []`.
|
|
1983
|
+
*
|
|
1984
|
+
* @example
|
|
1985
|
+
* const clock = () => Date.parse("2026-06-22T00:00:00Z");
|
|
1986
|
+
* const dt = dateTimeTool({}, { clock });
|
|
1987
|
+
* const { data } = await dt.invoke({ op: "now" });
|
|
1988
|
+
* console.log(data?.value); // "2026-06-22T00:00:00.000Z"
|
|
1989
|
+
*/
|
|
1990
|
+
function dateTimeTool(options, deps) {
|
|
1991
|
+
const clock = deps?.clock ?? Date.now;
|
|
1992
|
+
const defaultTimeZone = options?.defaultTimeZone;
|
|
1993
|
+
return (0, _warlock_js_ai.tool)({
|
|
1994
|
+
name: options?.name ?? DEFAULT_NAME$2,
|
|
1995
|
+
description: "Work with dates and times. Set op to: \"now\" (current instant), \"add\" (shift iso by amount of unit), \"diff\" (signed to − from in unit; pass the start instant as iso or from), or \"format\" (render iso). Units are milliseconds, seconds, minutes, hours, days, or weeks (month/year are not supported). Pass instants as ISO-8601 strings; set format to \"iso\" (default), \"date\", \"time\", or \"datetime\", and timeZone as an IANA zone like \"Africa/Cairo\".",
|
|
1996
|
+
input: inputSchema$2,
|
|
1997
|
+
async execute(input) {
|
|
1998
|
+
const timeZone = input.timeZone ?? defaultTimeZone;
|
|
1999
|
+
switch (input.op) {
|
|
2000
|
+
case "now": return { value: render$1(new Date(clock()), input.format, timeZone) };
|
|
2001
|
+
case "format": return { value: render$1(parseIso(input.iso, "iso"), input.format, timeZone) };
|
|
2002
|
+
case "add": {
|
|
2003
|
+
const date = parseIso(input.iso, "iso");
|
|
2004
|
+
if (input.amount === void 0) throw new DateTimeError("\"amount\" is required for the \"add\" operation.", { type: "invalid-input" });
|
|
2005
|
+
if (input.unit === void 0) throw new DateTimeError("\"unit\" is required for the \"add\" operation.", { type: "invalid-input" });
|
|
2006
|
+
return { value: render$1(new Date(date.getTime() + input.amount * unitToMs(input.unit)), input.format, timeZone) };
|
|
2007
|
+
}
|
|
2008
|
+
case "diff": {
|
|
2009
|
+
const from = parseIso(input.iso ?? input.from, "iso (or from)");
|
|
2010
|
+
const to = parseIso(input.to, "to");
|
|
2011
|
+
if (input.unit === void 0) throw new DateTimeError("\"unit\" is required for the \"diff\" operation.", { type: "invalid-input" });
|
|
2012
|
+
const diff = (to.getTime() - from.getTime()) / unitToMs(input.unit);
|
|
2013
|
+
return { value: String(diff) };
|
|
2014
|
+
}
|
|
2015
|
+
default: {
|
|
2016
|
+
const unreachable = input.op;
|
|
2017
|
+
throw new DateTimeError(`Unsupported operation "${String(unreachable)}".`, { type: "unsupported-op" });
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
//#endregion
|
|
2025
|
+
//#region ../@warlock.js/ai-tools/src/web/schema.ts
|
|
2026
|
+
/**
|
|
2027
|
+
* Tiny, dependency-free [Standard Schema](https://standardschema.dev)
|
|
2028
|
+
* builders for the web tools' input validation. The package pins only
|
|
2029
|
+
* `@warlock.js/ai` as a runtime peer, so rather than pull in a schema
|
|
2030
|
+
* library we hand-roll the few shapes the web tools need — exactly the
|
|
2031
|
+
* pattern `@warlock.js/ai`'s own `tool()` tests use and the sibling
|
|
2032
|
+
* `@warlock.js/ai-workspace` package follows. Each builder returns a
|
|
2033
|
+
* `StandardSchemaV1`, which is what `tool({ input })` validates against
|
|
2034
|
+
* before calling `execute`.
|
|
2035
|
+
*
|
|
2036
|
+
* These intentionally cover only the primitive cases the WEB tools
|
|
2037
|
+
* require (`string`, `optional number`, and an `object` of fields). They
|
|
2038
|
+
* are not a general-purpose validator.
|
|
2039
|
+
*/
|
|
2040
|
+
/** The vendor tag stamped on every issue these builders produce. */
|
|
2041
|
+
const VENDOR = "ai-tools";
|
|
2042
|
+
/** Required string field — rejects anything that is not a string. */
|
|
2043
|
+
function stringField() {
|
|
2044
|
+
return (value, key) => {
|
|
2045
|
+
if (typeof value === "string") return { value };
|
|
2046
|
+
return { issues: [{
|
|
2047
|
+
message: `"${key}" must be a string`,
|
|
2048
|
+
path: [key]
|
|
2049
|
+
}] };
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Optional finite-number field — accepts `undefined` or a finite number,
|
|
2054
|
+
* rejecting `NaN`/`Infinity` and non-number types.
|
|
2055
|
+
*/
|
|
2056
|
+
function optionalNumberField() {
|
|
2057
|
+
return (value, key) => {
|
|
2058
|
+
if (value === void 0) return { value: void 0 };
|
|
2059
|
+
if (typeof value === "number" && Number.isFinite(value)) return { value };
|
|
2060
|
+
return { issues: [{
|
|
2061
|
+
message: `"${key}" must be a finite number when provided`,
|
|
2062
|
+
path: [key]
|
|
2063
|
+
}] };
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Build a {@link StandardSchemaV1} for a flat object whose every property
|
|
2068
|
+
* is validated by a {@link FieldValidator}. The input must be a non-null
|
|
2069
|
+
* object; each declared field is validated and the (possibly coerced)
|
|
2070
|
+
* values are collected into the typed result. All field issues are merged
|
|
2071
|
+
* so the caller sees every problem at once.
|
|
2072
|
+
*
|
|
2073
|
+
* `T` is constrained to `object` rather than `Record<string, unknown>` so
|
|
2074
|
+
* the tool IO `interface`s (which carry no implicit string index
|
|
2075
|
+
* signature) satisfy it directly — only the declared keys in `shape` are
|
|
2076
|
+
* ever read, so a string index signature is never required.
|
|
2077
|
+
*
|
|
2078
|
+
* @example
|
|
2079
|
+
* const schema = objectSchema<{ query: string; maxResults?: number }>({
|
|
2080
|
+
* query: stringField(),
|
|
2081
|
+
* maxResults: optionalNumberField(),
|
|
2082
|
+
* });
|
|
2083
|
+
*/
|
|
2084
|
+
function objectSchema(shape) {
|
|
2085
|
+
return { "~standard": {
|
|
2086
|
+
version: 1,
|
|
2087
|
+
vendor: VENDOR,
|
|
2088
|
+
validate(input) {
|
|
2089
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
|
|
2090
|
+
const source = input;
|
|
2091
|
+
const issues = [];
|
|
2092
|
+
const result = {};
|
|
2093
|
+
for (const key of Object.keys(shape)) {
|
|
2094
|
+
const field = shape[key];
|
|
2095
|
+
const outcome = field(source[key], key);
|
|
2096
|
+
if ("issues" in outcome) {
|
|
2097
|
+
issues.push(...outcome.issues);
|
|
2098
|
+
continue;
|
|
2099
|
+
}
|
|
2100
|
+
if (outcome.value !== void 0) result[key] = outcome.value;
|
|
2101
|
+
}
|
|
2102
|
+
if (issues.length > 0) return { issues };
|
|
2103
|
+
return { value: result };
|
|
2104
|
+
}
|
|
2105
|
+
} };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
//#endregion
|
|
2109
|
+
//#region ../@warlock.js/ai-tools/src/web/fetch-url.ts
|
|
2110
|
+
/** Default tool name exposed to the LLM. */
|
|
2111
|
+
const DEFAULT_NAME$1 = "fetch_url";
|
|
2112
|
+
/** Default hard cap on response bytes read before truncation. */
|
|
2113
|
+
const DEFAULT_MAX_BYTES = 1e6;
|
|
2114
|
+
/** Default per-request timeout in milliseconds. */
|
|
2115
|
+
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
2116
|
+
/** Default rendering mode for the fetched document. */
|
|
2117
|
+
const DEFAULT_EXTRACT = "text";
|
|
2118
|
+
/** Input schema: `{ url }`. */
|
|
2119
|
+
const inputSchema$1 = objectSchema({ url: stringField() });
|
|
2120
|
+
const READABILITY_MODULE_ID = "@mozilla/readability";
|
|
2121
|
+
const JSDOM_MODULE_ID = "jsdom";
|
|
2122
|
+
let ReadabilitySdk;
|
|
2123
|
+
let JsdomSdk;
|
|
2124
|
+
let isReadabilityAvailable;
|
|
2125
|
+
let loadingPromise;
|
|
2126
|
+
const READABILITY_INSTALL_INSTRUCTIONS = `
|
|
2127
|
+
The fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.
|
|
2128
|
+
Install them with:
|
|
2129
|
+
|
|
2130
|
+
npm install @mozilla/readability jsdom
|
|
2131
|
+
|
|
2132
|
+
Or with your preferred package manager:
|
|
2133
|
+
|
|
2134
|
+
pnpm add @mozilla/readability jsdom
|
|
2135
|
+
yarn add @mozilla/readability jsdom
|
|
2136
|
+
`.trim();
|
|
2137
|
+
/**
|
|
2138
|
+
* Settle the lazy import of `@mozilla/readability` + `jsdom` once,
|
|
2139
|
+
* concurrency-safe. Only needed for the `"text"` / `"markdown"` extract
|
|
2140
|
+
* modes — `"html"` returns the raw body and never loads them. A bare
|
|
2141
|
+
* `catch` flips the flag to `false`; the curated install string surfaces
|
|
2142
|
+
* at use time via {@link WebToolError}, never a raw module-resolution
|
|
2143
|
+
* stack trace.
|
|
2144
|
+
*/
|
|
2145
|
+
function loadReadability() {
|
|
2146
|
+
if (isReadabilityAvailable !== void 0) return Promise.resolve();
|
|
2147
|
+
if (loadingPromise) return loadingPromise;
|
|
2148
|
+
loadingPromise = (async () => {
|
|
2149
|
+
try {
|
|
2150
|
+
ReadabilitySdk = await import(READABILITY_MODULE_ID);
|
|
2151
|
+
JsdomSdk = await import(JSDOM_MODULE_ID);
|
|
2152
|
+
isReadabilityAvailable = true;
|
|
2153
|
+
} catch {
|
|
2154
|
+
isReadabilityAvailable = false;
|
|
2155
|
+
}
|
|
2156
|
+
})();
|
|
2157
|
+
return loadingPromise;
|
|
2158
|
+
}
|
|
2159
|
+
/**
|
|
2160
|
+
* Parse the input URL, rejecting anything unparseable or not over
|
|
2161
|
+
* `http`/`https` with a typed {@link WebToolError}.
|
|
2162
|
+
*/
|
|
2163
|
+
function parseUrl(raw) {
|
|
2164
|
+
let url;
|
|
2165
|
+
try {
|
|
2166
|
+
url = new URL(raw);
|
|
2167
|
+
} catch {
|
|
2168
|
+
throw new WebToolError(`fetch_url received an unparseable URL: "${raw}".`, { type: "invalid-url" });
|
|
2169
|
+
}
|
|
2170
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new WebToolError(`fetch_url only supports http(s) URLs; got "${url.protocol}".`, { type: "invalid-url" });
|
|
2171
|
+
return url;
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Enforce the host allowlist (an SSRF guardrail) before any network call.
|
|
2175
|
+
* When `allowHosts` is set, a request to a host not in it is rejected;
|
|
2176
|
+
* matching is exact on the URL hostname (case-insensitive).
|
|
2177
|
+
*/
|
|
2178
|
+
function assertHostAllowed(url, allowHosts) {
|
|
2179
|
+
if (!allowHosts || allowHosts.length === 0) return;
|
|
2180
|
+
const host = url.hostname.toLowerCase();
|
|
2181
|
+
if (!allowHosts.some((entry) => entry.toLowerCase() === host)) throw new WebToolError(`fetch_url blocked host "${url.hostname}" — it is not in the allowHosts allowlist.`, {
|
|
2182
|
+
type: "denied-host",
|
|
2183
|
+
context: { host: url.hostname }
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Read the response body up to `maxBytes`, stopping early once the cap is
|
|
2188
|
+
* reached. Returns the decoded text and whether it was truncated. When
|
|
2189
|
+
* the body has no stream (a stubbed `Response`), falls back to `.text()`
|
|
2190
|
+
* and truncates the decoded string at `maxBytes`.
|
|
2191
|
+
*/
|
|
2192
|
+
async function readBody(response, maxBytes) {
|
|
2193
|
+
const stream = response.body;
|
|
2194
|
+
if (!stream) {
|
|
2195
|
+
const text = await response.text();
|
|
2196
|
+
if (text.length > maxBytes) return {
|
|
2197
|
+
body: text.slice(0, maxBytes),
|
|
2198
|
+
truncated: true
|
|
2199
|
+
};
|
|
2200
|
+
return {
|
|
2201
|
+
body: text,
|
|
2202
|
+
truncated: false
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
const reader = stream.getReader();
|
|
2206
|
+
const chunks = [];
|
|
2207
|
+
let received = 0;
|
|
2208
|
+
let truncated = false;
|
|
2209
|
+
for (;;) {
|
|
2210
|
+
const { done, value } = await reader.read();
|
|
2211
|
+
if (done) break;
|
|
2212
|
+
if (value) {
|
|
2213
|
+
received += value.byteLength;
|
|
2214
|
+
chunks.push(value);
|
|
2215
|
+
if (received >= maxBytes) {
|
|
2216
|
+
truncated = true;
|
|
2217
|
+
await reader.cancel();
|
|
2218
|
+
break;
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
const merged = new Uint8Array(received);
|
|
2223
|
+
let offset = 0;
|
|
2224
|
+
for (const chunk of chunks) {
|
|
2225
|
+
merged.set(chunk, offset);
|
|
2226
|
+
offset += chunk.byteLength;
|
|
2227
|
+
}
|
|
2228
|
+
const sliced = truncated ? merged.subarray(0, maxBytes) : merged;
|
|
2229
|
+
return {
|
|
2230
|
+
body: new TextDecoder().decode(sliced),
|
|
2231
|
+
truncated
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
/**
|
|
2235
|
+
* Render the fetched HTML into the configured `extract` mode. `"html"`
|
|
2236
|
+
* returns the raw body untouched; `"text"` and `"markdown"` run
|
|
2237
|
+
* `@mozilla/readability` over a `jsdom` document, throwing a curated
|
|
2238
|
+
* {@link WebToolError} when those optional peers are absent. `"markdown"`
|
|
2239
|
+
* applies a light heading/paragraph conversion over the extracted text.
|
|
2240
|
+
*/
|
|
2241
|
+
async function render(html, url, extract) {
|
|
2242
|
+
if (extract === "html") return html;
|
|
2243
|
+
await loadReadability();
|
|
2244
|
+
if (!isReadabilityAvailable) throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: "missing-peer" });
|
|
2245
|
+
const dom = new JsdomSdk.JSDOM(html, { url });
|
|
2246
|
+
const article = new ReadabilitySdk.Readability(dom.window.document).parse();
|
|
2247
|
+
if (extract === "markdown") return article?.content ? htmlToMarkdown(article.content) : article?.textContent ?? "";
|
|
2248
|
+
return article?.textContent ?? "";
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* Minimal HTML→Markdown reduction for readability's extracted article
|
|
2252
|
+
* HTML — headings become `#` prefixes, paragraphs/line-breaks become
|
|
2253
|
+
* blank-line separated blocks, and remaining tags are stripped. This is a
|
|
2254
|
+
* pragmatic conversion, not a full CommonMark serializer.
|
|
2255
|
+
*/
|
|
2256
|
+
function htmlToMarkdown(html) {
|
|
2257
|
+
return html.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_match, level, text) => {
|
|
2258
|
+
return `\n\n${"#".repeat(Number(level))} ${stripTags(text).trim()}\n\n`;
|
|
2259
|
+
}).replace(/<\/(p|div|section|article|li)>/gi, "\n\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
2260
|
+
}
|
|
2261
|
+
/** Strip any remaining HTML tags from a fragment. */
|
|
2262
|
+
function stripTags(html) {
|
|
2263
|
+
return html.replace(/<[^>]+>/g, "");
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Build the agent-facing `fetch_url` tool — fetch a URL over the global
|
|
2267
|
+
* `fetch` (Node 18+) and hand the model back rendered `content`.
|
|
2268
|
+
*
|
|
2269
|
+
* Guardrails, applied in order before/around the network call:
|
|
2270
|
+
* - **`allowHosts`** — when set, a request to any host not in the list is
|
|
2271
|
+
* rejected before the fetch (an SSRF guardrail).
|
|
2272
|
+
* - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`
|
|
2273
|
+
* (default {@link DEFAULT_TIMEOUT_MS}).
|
|
2274
|
+
* - **`maxBytes`** — the response body is read up to this cap and the
|
|
2275
|
+
* result flags `truncated` when it was cut off (default
|
|
2276
|
+
* {@link DEFAULT_MAX_BYTES}).
|
|
2277
|
+
* - **`extract`** — `"html"` returns the raw body; `"text"` (default) and
|
|
2278
|
+
* `"markdown"` run `@mozilla/readability` over `jsdom`, lazily imported
|
|
2279
|
+
* so they stay OPTIONAL peers — a missing peer throws the curated
|
|
2280
|
+
* install string.
|
|
2281
|
+
*
|
|
2282
|
+
* **Errors flow as data.** Every guardrail rejection and network failure
|
|
2283
|
+
* throws a typed {@link WebToolError}; the `tool()` wrapper catches it and
|
|
2284
|
+
* surfaces it in the returned `{ error }` field — `invoke()` never throws
|
|
2285
|
+
* — so the agent reads the failure and self-corrects.
|
|
2286
|
+
*
|
|
2287
|
+
* @param options - Tool-name override, byte cap, timeout, extract mode,
|
|
2288
|
+
* and host allowlist.
|
|
2289
|
+
* @returns A `ToolContract<{ url }, FetchUrlResult>`.
|
|
2290
|
+
*
|
|
2291
|
+
* @example
|
|
2292
|
+
* const fetchUrl = fetchUrlTool({ extract: "text", allowHosts: ["docs.stripe.com"] });
|
|
2293
|
+
* const { data } = await fetchUrl.invoke({ url: "https://docs.stripe.com/api" });
|
|
2294
|
+
* console.log(data?.content, data?.truncated);
|
|
2295
|
+
*/
|
|
2296
|
+
function fetchUrlTool(options) {
|
|
2297
|
+
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
2298
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2299
|
+
const extract = options?.extract ?? DEFAULT_EXTRACT;
|
|
2300
|
+
const allowHosts = options?.allowHosts;
|
|
2301
|
+
if (extract !== "html") loadReadability();
|
|
2302
|
+
return (0, _warlock_js_ai.tool)({
|
|
2303
|
+
name: options?.name ?? DEFAULT_NAME$1,
|
|
2304
|
+
description: "Fetch a web page or document by URL and return its main content as text (readability-extracted), raw HTML, or Markdown. Use to read a specific page you already have the URL for.",
|
|
2305
|
+
input: inputSchema$1,
|
|
2306
|
+
async execute(input) {
|
|
2307
|
+
const url = parseUrl(input.url);
|
|
2308
|
+
assertHostAllowed(url, allowHosts);
|
|
2309
|
+
let response;
|
|
2310
|
+
try {
|
|
2311
|
+
response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
2312
|
+
} catch (cause) {
|
|
2313
|
+
throw new WebToolError(`fetch_url request failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
2314
|
+
type: "request-failed",
|
|
2315
|
+
cause
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
const { body, truncated } = await readBody(response, maxBytes);
|
|
2319
|
+
const content = await render(body, response.url || url.toString(), extract);
|
|
2320
|
+
return {
|
|
2321
|
+
url: response.url || url.toString(),
|
|
2322
|
+
status: response.status,
|
|
2323
|
+
content,
|
|
2324
|
+
truncated
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
//#endregion
|
|
2331
|
+
//#region ../@warlock.js/ai-tools/src/web/web-search.ts
|
|
2332
|
+
/** Default tool name exposed to the LLM. */
|
|
2333
|
+
const DEFAULT_NAME = "web_search";
|
|
2334
|
+
/** Default per-call result cap when the constructor does not set one. */
|
|
2335
|
+
const DEFAULT_MAX_RESULTS = 5;
|
|
2336
|
+
/**
|
|
2337
|
+
* Per-provider configuration — the environment variable consulted when no
|
|
2338
|
+
* `apiKey` is passed, plus the human-readable provider label used in
|
|
2339
|
+
* error messages.
|
|
2340
|
+
*/
|
|
2341
|
+
const PROVIDER_ENV = {
|
|
2342
|
+
tavily: "TAVILY_API_KEY",
|
|
2343
|
+
brave: "BRAVE_API_KEY",
|
|
2344
|
+
serpapi: "SERPAPI_API_KEY"
|
|
2345
|
+
};
|
|
2346
|
+
/** Input schema: `{ query, maxResults? }`. */
|
|
2347
|
+
const inputSchema = objectSchema({
|
|
2348
|
+
query: stringField(),
|
|
2349
|
+
maxResults: optionalNumberField()
|
|
2350
|
+
});
|
|
2351
|
+
/**
|
|
2352
|
+
* Resolve the API key from explicit options or the provider's environment
|
|
2353
|
+
* variable, throwing a typed {@link WebToolError} when neither is present.
|
|
2354
|
+
*/
|
|
2355
|
+
function resolveApiKey(provider, apiKey) {
|
|
2356
|
+
const key = apiKey ?? process.env[PROVIDER_ENV[provider]];
|
|
2357
|
+
if (!key) throw new WebToolError(`web_search requires an API key for the "${provider}" provider. Pass { apiKey } or set the ${PROVIDER_ENV[provider]} environment variable.`, { type: "missing-key" });
|
|
2358
|
+
return key;
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Clamp the model-requested result count into `[1, max]`. An omitted /
|
|
2362
|
+
* non-positive request falls back to the configured default.
|
|
2363
|
+
*/
|
|
2364
|
+
function clampResults(requested, max) {
|
|
2365
|
+
if (requested === void 0 || requested < 1) return Math.min(DEFAULT_MAX_RESULTS, max);
|
|
2366
|
+
return Math.min(Math.max(1, Math.floor(requested)), max);
|
|
2367
|
+
}
|
|
2368
|
+
/**
|
|
2369
|
+
* Issue the provider HTTP call and return its parsed JSON, mapping a
|
|
2370
|
+
* non-OK status or a network failure to a typed {@link WebToolError}.
|
|
2371
|
+
*/
|
|
2372
|
+
async function fetchJson(url, init, provider) {
|
|
2373
|
+
let response;
|
|
2374
|
+
try {
|
|
2375
|
+
response = await fetch(url, init);
|
|
2376
|
+
} catch (cause) {
|
|
2377
|
+
throw new WebToolError(`web_search request to the "${provider}" provider failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
2378
|
+
type: "request-failed",
|
|
2379
|
+
cause
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
if (!response.ok) throw new WebToolError(`web_search "${provider}" provider returned HTTP ${response.status}.`, {
|
|
2383
|
+
type: "request-failed",
|
|
2384
|
+
context: { status: response.status }
|
|
2385
|
+
});
|
|
2386
|
+
return response.json();
|
|
2387
|
+
}
|
|
2388
|
+
/** Drive Tavily's `/search` HTTP API and normalize its hits. */
|
|
2389
|
+
async function searchTavily(query, maxResults, apiKey) {
|
|
2390
|
+
return ((await fetchJson("https://api.tavily.com/search", {
|
|
2391
|
+
method: "POST",
|
|
2392
|
+
headers: { "content-type": "application/json" },
|
|
2393
|
+
body: JSON.stringify({
|
|
2394
|
+
api_key: apiKey,
|
|
2395
|
+
query,
|
|
2396
|
+
max_results: maxResults
|
|
2397
|
+
})
|
|
2398
|
+
}, "tavily")).results ?? []).slice(0, maxResults).map((hit) => ({
|
|
2399
|
+
title: hit.title ?? "",
|
|
2400
|
+
url: hit.url ?? "",
|
|
2401
|
+
snippet: hit.content ?? "",
|
|
2402
|
+
score: hit.score
|
|
2403
|
+
}));
|
|
2404
|
+
}
|
|
2405
|
+
/** Drive Brave's web-search HTTP API and normalize its hits. */
|
|
2406
|
+
async function searchBrave(query, maxResults, apiKey) {
|
|
2407
|
+
const url = new URL("https://api.search.brave.com/res/v1/web/search");
|
|
2408
|
+
url.searchParams.set("q", query);
|
|
2409
|
+
url.searchParams.set("count", String(maxResults));
|
|
2410
|
+
return ((await fetchJson(url.toString(), {
|
|
2411
|
+
method: "GET",
|
|
2412
|
+
headers: {
|
|
2413
|
+
accept: "application/json",
|
|
2414
|
+
"x-subscription-token": apiKey
|
|
2415
|
+
}
|
|
2416
|
+
}, "brave")).web?.results ?? []).slice(0, maxResults).map((hit) => ({
|
|
2417
|
+
title: hit.title ?? "",
|
|
2418
|
+
url: hit.url ?? "",
|
|
2419
|
+
snippet: hit.description ?? ""
|
|
2420
|
+
}));
|
|
2421
|
+
}
|
|
2422
|
+
/** Drive SerpAPI's `search.json` HTTP API and normalize its hits. */
|
|
2423
|
+
async function searchSerpApi(query, maxResults, apiKey) {
|
|
2424
|
+
const url = new URL("https://serpapi.com/search.json");
|
|
2425
|
+
url.searchParams.set("q", query);
|
|
2426
|
+
url.searchParams.set("num", String(maxResults));
|
|
2427
|
+
url.searchParams.set("api_key", apiKey);
|
|
2428
|
+
return ((await fetchJson(url.toString(), { method: "GET" }, "serpapi")).organic_results ?? []).slice(0, maxResults).map((hit) => ({
|
|
2429
|
+
title: hit.title ?? "",
|
|
2430
|
+
url: hit.link ?? "",
|
|
2431
|
+
snippet: hit.snippet ?? ""
|
|
2432
|
+
}));
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* Build the agent-facing `web_search` tool over a {@link SearchProvider}.
|
|
2436
|
+
*
|
|
2437
|
+
* The provider's HTTP API is called directly through the global `fetch`
|
|
2438
|
+
* (Node 18+) — Tavily via `POST /search` (LLM-ready snippets + relevance
|
|
2439
|
+
* scores), Brave and SerpAPI via their `GET` endpoints. The API key is
|
|
2440
|
+
* taken from `options.apiKey`, falling back to the provider's environment
|
|
2441
|
+
* variable (`TAVILY_API_KEY` / `BRAVE_API_KEY` / `SERPAPI_API_KEY`). No
|
|
2442
|
+
* provider SDK is required for the HTTP path; `@tavily/core` remains an
|
|
2443
|
+
* optional peer for callers who prefer it, but this factory never forces
|
|
2444
|
+
* it to be installed.
|
|
2445
|
+
*
|
|
2446
|
+
* The model passes `{ query, maxResults? }`; `maxResults` is clamped into
|
|
2447
|
+
* `[1, options.maxResults]` (default {@link DEFAULT_MAX_RESULTS}).
|
|
2448
|
+
*
|
|
2449
|
+
* **Errors flow as data.** A missing key, a non-OK provider status, or a
|
|
2450
|
+
* network failure throws a typed {@link WebToolError}; the `tool()`
|
|
2451
|
+
* wrapper catches it and surfaces it in the returned `{ error }` field —
|
|
2452
|
+
* `invoke()` never throws — so the agent can read the failure and
|
|
2453
|
+
* self-correct.
|
|
2454
|
+
*
|
|
2455
|
+
* @param options - Provider selection, API key, result cap, and an
|
|
2456
|
+
* optional tool-name override.
|
|
2457
|
+
* @returns A `ToolContract<{ query; maxResults? }, WebSearchResult>`.
|
|
2458
|
+
*
|
|
2459
|
+
* @example
|
|
2460
|
+
* const search = webSearchTool({ provider: "tavily" });
|
|
2461
|
+
* const { data } = await search.invoke({ query: "warlock.js ai tools" });
|
|
2462
|
+
* for (const hit of data?.results ?? []) console.log(hit.title, hit.url);
|
|
2463
|
+
*/
|
|
2464
|
+
function webSearchTool(options) {
|
|
2465
|
+
const provider = options.provider;
|
|
2466
|
+
const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
|
|
2467
|
+
return (0, _warlock_js_ai.tool)({
|
|
2468
|
+
name: options.name ?? DEFAULT_NAME,
|
|
2469
|
+
description: "Search the web for current information and return a ranked list of results (title, URL, and a short snippet). Use for facts that may have changed since training, or anything you are unsure about.",
|
|
2470
|
+
input: inputSchema,
|
|
2471
|
+
async execute(input) {
|
|
2472
|
+
const apiKey = resolveApiKey(provider, options.apiKey);
|
|
2473
|
+
const limit = clampResults(input.maxResults, maxResults);
|
|
2474
|
+
let results;
|
|
2475
|
+
switch (provider) {
|
|
2476
|
+
case "tavily":
|
|
2477
|
+
results = await searchTavily(input.query, limit, apiKey);
|
|
2478
|
+
break;
|
|
2479
|
+
case "brave":
|
|
2480
|
+
results = await searchBrave(input.query, limit, apiKey);
|
|
2481
|
+
break;
|
|
2482
|
+
case "serpapi":
|
|
2483
|
+
results = await searchSerpApi(input.query, limit, apiKey);
|
|
2484
|
+
break;
|
|
2485
|
+
}
|
|
2486
|
+
return { results };
|
|
2487
|
+
}
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
//#endregion
|
|
2492
|
+
//#region ../@warlock.js/ai-tools/src/register.ts
|
|
2493
|
+
_warlock_js_ai.ai.tools = {
|
|
2494
|
+
webSearch: webSearchTool,
|
|
2495
|
+
fetchUrl: fetchUrlTool,
|
|
2496
|
+
http: httpRequestTool,
|
|
2497
|
+
calculator: calculatorTool,
|
|
2498
|
+
dateTime: (options) => dateTimeTool(options)
|
|
2499
|
+
};
|
|
2500
|
+
_warlock_js_ai.ai.mcp = mcp;
|
|
2501
|
+
|
|
2502
|
+
//#endregion
|
|
2503
|
+
exports.CalculatorError = CalculatorError;
|
|
2504
|
+
exports.DateTimeError = DateTimeError;
|
|
2505
|
+
exports.HttpPolicyError = HttpPolicyError;
|
|
2506
|
+
exports.McpTransportError = McpTransportError;
|
|
2507
|
+
exports.WebToolError = WebToolError;
|
|
2508
|
+
exports.calculatorTool = calculatorTool;
|
|
2509
|
+
exports.createJsonRpcClient = createJsonRpcClient;
|
|
2510
|
+
exports.createServeHandler = createServeHandler;
|
|
2511
|
+
exports.createTransport = createTransport;
|
|
2512
|
+
exports.dateTimeTool = dateTimeTool;
|
|
2513
|
+
exports.fetchUrlTool = fetchUrlTool;
|
|
2514
|
+
exports.httpRequestTool = httpRequestTool;
|
|
2515
|
+
exports.jsonSchemaToStandard = jsonSchemaToStandard;
|
|
2516
|
+
exports.mcp = mcp;
|
|
2517
|
+
exports.serve = serve;
|
|
2518
|
+
exports.webSearchTool = webSearchTool;
|
|
2519
|
+
//# sourceMappingURL=index.cjs.map
|