@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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { DateTimeError } from "../errors.mjs";
|
|
2
|
+
import { literalField, objectSchema, optionalNumberField, optionalStringField } from "./schema.mjs";
|
|
3
|
+
import { tool } from "@warlock.js/ai";
|
|
4
|
+
|
|
5
|
+
//#region ../@warlock.js/ai-tools/src/utility/date-time.ts
|
|
6
|
+
/** Default tool name exposed to the LLM. */
|
|
7
|
+
const DEFAULT_NAME = "date_time";
|
|
8
|
+
/** Input schema for the `date_time` tool. */
|
|
9
|
+
const inputSchema = objectSchema({
|
|
10
|
+
op: literalField([
|
|
11
|
+
"now",
|
|
12
|
+
"add",
|
|
13
|
+
"diff",
|
|
14
|
+
"format"
|
|
15
|
+
]),
|
|
16
|
+
iso: optionalStringField(),
|
|
17
|
+
from: optionalStringField(),
|
|
18
|
+
to: optionalStringField(),
|
|
19
|
+
amount: optionalNumberField(),
|
|
20
|
+
unit: optionalStringField(),
|
|
21
|
+
timeZone: optionalStringField(),
|
|
22
|
+
format: optionalStringField()
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* The time units `add` and `diff` understand, each as its length in
|
|
26
|
+
* milliseconds. Calendar-unaware on purpose: a "month" / "year" has no
|
|
27
|
+
* fixed millisecond length, so they are deliberately excluded — adding
|
|
28
|
+
* `30 days` is unambiguous in a way adding `1 month` is not.
|
|
29
|
+
*/
|
|
30
|
+
const UNIT_MS = {
|
|
31
|
+
milliseconds: 1,
|
|
32
|
+
seconds: 1e3,
|
|
33
|
+
minutes: 6e4,
|
|
34
|
+
hours: 36e5,
|
|
35
|
+
days: 864e5,
|
|
36
|
+
weeks: 6048e5
|
|
37
|
+
};
|
|
38
|
+
/** Singular aliases mapped to their canonical plural unit key. */
|
|
39
|
+
const UNIT_ALIASES = {
|
|
40
|
+
millisecond: "milliseconds",
|
|
41
|
+
ms: "milliseconds",
|
|
42
|
+
second: "seconds",
|
|
43
|
+
sec: "seconds",
|
|
44
|
+
s: "seconds",
|
|
45
|
+
minute: "minutes",
|
|
46
|
+
min: "minutes",
|
|
47
|
+
m: "minutes",
|
|
48
|
+
hour: "hours",
|
|
49
|
+
hr: "hours",
|
|
50
|
+
h: "hours",
|
|
51
|
+
day: "days",
|
|
52
|
+
d: "days",
|
|
53
|
+
week: "weeks",
|
|
54
|
+
w: "weeks"
|
|
55
|
+
};
|
|
56
|
+
/** Resolve a (possibly aliased / singular) unit token to its ms length. */
|
|
57
|
+
function unitToMs(unit) {
|
|
58
|
+
const ms = UNIT_MS[UNIT_ALIASES[unit] ?? unit];
|
|
59
|
+
if (ms === void 0) throw new DateTimeError(`Unknown unit "${unit}". Supported units: ${Object.keys(UNIT_MS).join(", ")}.`, { type: "invalid-unit" });
|
|
60
|
+
return ms;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse an ISO-8601 instant into a {@link Date}, or throw a typed
|
|
64
|
+
* {@link DateTimeError} when the string is missing or unparseable.
|
|
65
|
+
*
|
|
66
|
+
* @param iso - The ISO string from the model (may be undefined).
|
|
67
|
+
* @param field - The input field name, for the error message.
|
|
68
|
+
*/
|
|
69
|
+
function parseIso(iso, field) {
|
|
70
|
+
if (iso === void 0) throw new DateTimeError(`"${field}" is required for this operation.`, { type: "invalid-input" });
|
|
71
|
+
const date = new Date(iso);
|
|
72
|
+
if (Number.isNaN(date.getTime())) throw new DateTimeError(`"${field}" is not a valid ISO-8601 instant: "${iso}".`, { type: "invalid-input" });
|
|
73
|
+
return date;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Render a {@link Date} in a target time zone using `Intl`. The
|
|
77
|
+
* `"iso"` format (the default) returns the instant's UTC ISO string;
|
|
78
|
+
* any other `format` value is treated as an `Intl.DateTimeFormat`
|
|
79
|
+
* locale-style rendering in the given `timeZone`.
|
|
80
|
+
*
|
|
81
|
+
* @throws DateTimeError `type: "invalid-time-zone"` when `timeZone` is
|
|
82
|
+
* not a recognized IANA zone.
|
|
83
|
+
*/
|
|
84
|
+
function render(date, format, timeZone) {
|
|
85
|
+
if (format === void 0 || format === "iso") {
|
|
86
|
+
if (timeZone !== void 0) assertTimeZone(timeZone);
|
|
87
|
+
return date.toISOString();
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
91
|
+
timeZone,
|
|
92
|
+
dateStyle: format === "date" ? "medium" : void 0,
|
|
93
|
+
timeStyle: format === "time" ? "medium" : void 0,
|
|
94
|
+
...format === "datetime" ? {
|
|
95
|
+
dateStyle: "medium",
|
|
96
|
+
timeStyle: "medium"
|
|
97
|
+
} : {}
|
|
98
|
+
}).format(date);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new DateTimeError(`Could not render with format "${format}"${timeZone ? ` in time zone "${timeZone}"` : ""}.`, {
|
|
101
|
+
type: "invalid-time-zone",
|
|
102
|
+
cause: error
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Validate an IANA time zone by attempting to construct a formatter for
|
|
108
|
+
* it; an unrecognized zone makes `Intl` throw a `RangeError`.
|
|
109
|
+
*
|
|
110
|
+
* @throws DateTimeError `type: "invalid-time-zone"` for an unknown zone.
|
|
111
|
+
*/
|
|
112
|
+
function assertTimeZone(timeZone) {
|
|
113
|
+
try {
|
|
114
|
+
new Intl.DateTimeFormat("en-US", { timeZone });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new DateTimeError(`Unknown time zone "${timeZone}".`, {
|
|
117
|
+
type: "invalid-time-zone",
|
|
118
|
+
cause: error
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build the `date_time` tool — a small clock/calendar utility the agent
|
|
124
|
+
* can call to read the current instant, shift an instant, measure the
|
|
125
|
+
* gap between two instants, or render one. The `op` discriminator selects
|
|
126
|
+
* the operation:
|
|
127
|
+
*
|
|
128
|
+
* - **`now`** — the current instant, rendered per `format` / `timeZone`
|
|
129
|
+
* (defaults to a UTC ISO string). Reads the injectable {@link Clock}.
|
|
130
|
+
* - **`add`** — `iso` shifted by `amount` of `unit` (e.g.
|
|
131
|
+
* `+3 days`); a negative `amount` shifts backward.
|
|
132
|
+
* - **`diff`** — the signed difference `to − iso`, expressed in `unit`.
|
|
133
|
+
* - **`format`** — `iso` rendered per `format` / `timeZone`.
|
|
134
|
+
*
|
|
135
|
+
* Units are millisecond-based (`milliseconds`…`weeks`, plus common
|
|
136
|
+
* aliases); calendar-relative `month`/`year` are intentionally
|
|
137
|
+
* unsupported because they have no fixed length.
|
|
138
|
+
*
|
|
139
|
+
* **Deterministic in tests.** The clock backing `now` is injectable via
|
|
140
|
+
* `deps.clock`; production defaults to `Date.now`.
|
|
141
|
+
*
|
|
142
|
+
* **Errors flow as data.** A missing/invalid field, an unknown unit, or
|
|
143
|
+
* an unrecognized time zone throws a {@link DateTimeError} inside the
|
|
144
|
+
* handler; `tool()` surfaces it in `{ error }` so the agent self-corrects.
|
|
145
|
+
*
|
|
146
|
+
* @param options - Optional overrides; `name` renames the tool,
|
|
147
|
+
* `defaultTimeZone` applies when a call omits `timeZone`.
|
|
148
|
+
* @param deps - Injectable dependencies (the {@link Clock}); defaults to real time.
|
|
149
|
+
* @returns A `ToolContract<DateTimeInput, { value }>` ready for `tools: []`.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* const clock = () => Date.parse("2026-06-22T00:00:00Z");
|
|
153
|
+
* const dt = dateTimeTool({}, { clock });
|
|
154
|
+
* const { data } = await dt.invoke({ op: "now" });
|
|
155
|
+
* console.log(data?.value); // "2026-06-22T00:00:00.000Z"
|
|
156
|
+
*/
|
|
157
|
+
function dateTimeTool(options, deps) {
|
|
158
|
+
const clock = deps?.clock ?? Date.now;
|
|
159
|
+
const defaultTimeZone = options?.defaultTimeZone;
|
|
160
|
+
return tool({
|
|
161
|
+
name: options?.name ?? DEFAULT_NAME,
|
|
162
|
+
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\".",
|
|
163
|
+
input: inputSchema,
|
|
164
|
+
async execute(input) {
|
|
165
|
+
const timeZone = input.timeZone ?? defaultTimeZone;
|
|
166
|
+
switch (input.op) {
|
|
167
|
+
case "now": return { value: render(new Date(clock()), input.format, timeZone) };
|
|
168
|
+
case "format": return { value: render(parseIso(input.iso, "iso"), input.format, timeZone) };
|
|
169
|
+
case "add": {
|
|
170
|
+
const date = parseIso(input.iso, "iso");
|
|
171
|
+
if (input.amount === void 0) throw new DateTimeError("\"amount\" is required for the \"add\" operation.", { type: "invalid-input" });
|
|
172
|
+
if (input.unit === void 0) throw new DateTimeError("\"unit\" is required for the \"add\" operation.", { type: "invalid-input" });
|
|
173
|
+
return { value: render(new Date(date.getTime() + input.amount * unitToMs(input.unit)), input.format, timeZone) };
|
|
174
|
+
}
|
|
175
|
+
case "diff": {
|
|
176
|
+
const from = parseIso(input.iso ?? input.from, "iso (or from)");
|
|
177
|
+
const to = parseIso(input.to, "to");
|
|
178
|
+
if (input.unit === void 0) throw new DateTimeError("\"unit\" is required for the \"diff\" operation.", { type: "invalid-input" });
|
|
179
|
+
const diff = (to.getTime() - from.getTime()) / unitToMs(input.unit);
|
|
180
|
+
return { value: String(diff) };
|
|
181
|
+
}
|
|
182
|
+
default: {
|
|
183
|
+
const unreachable = input.op;
|
|
184
|
+
throw new DateTimeError(`Unsupported operation "${String(unreachable)}".`, { type: "unsupported-op" });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
export { dateTimeTool };
|
|
193
|
+
//# sourceMappingURL=date-time.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"date-time.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-tools/src/utility/date-time.ts"],"sourcesContent":["import { tool, type ToolContract } from \"@warlock.js/ai\";\nimport { DateTimeError } from \"../errors\";\nimport { literalField, objectSchema, optionalNumberField, optionalStringField } from \"./schema\";\nimport type {\n DateTimeInput,\n DateTimeOp,\n DateTimeOptions,\n DateTimeResult,\n} from \"../contracts/utility.type\";\n\n/** Default tool name exposed to the LLM. */\nconst DEFAULT_NAME = \"date_time\";\n\n/** The closed set of operations the tool implements. */\nconst OPS: readonly DateTimeOp[] = [\"now\", \"add\", \"diff\", \"format\"];\n\n/**\n * A clock seam so tests are deterministic. Defaults to the real\n * {@link Date}; a test injects a fixed instant. Returns milliseconds\n * since the Unix epoch — the same contract as `Date.now()`.\n */\nexport type Clock = () => number;\n\n/** Construction dependencies for {@link dateTimeTool}, all optional. */\nexport interface DateTimeToolDeps {\n /**\n * The clock used by the `now` op. Defaults to `Date.now`. Injected in\n * tests for deterministic output.\n */\n clock?: Clock;\n}\n\n/** Input schema for the `date_time` tool. */\nconst inputSchema = objectSchema<DateTimeInput>({\n op: literalField<DateTimeOp>(OPS),\n iso: optionalStringField(),\n from: optionalStringField(),\n to: optionalStringField(),\n amount: optionalNumberField(),\n unit: optionalStringField(),\n timeZone: optionalStringField(),\n format: optionalStringField(),\n});\n\n/**\n * The time units `add` and `diff` understand, each as its length in\n * milliseconds. Calendar-unaware on purpose: a \"month\" / \"year\" has no\n * fixed millisecond length, so they are deliberately excluded — adding\n * `30 days` is unambiguous in a way adding `1 month` is not.\n */\nconst UNIT_MS: Record<string, number> = {\n milliseconds: 1,\n seconds: 1_000,\n minutes: 60_000,\n hours: 3_600_000,\n days: 86_400_000,\n weeks: 604_800_000,\n};\n\n/** Singular aliases mapped to their canonical plural unit key. */\nconst UNIT_ALIASES: Record<string, string> = {\n millisecond: \"milliseconds\",\n ms: \"milliseconds\",\n second: \"seconds\",\n sec: \"seconds\",\n s: \"seconds\",\n minute: \"minutes\",\n min: \"minutes\",\n m: \"minutes\",\n hour: \"hours\",\n hr: \"hours\",\n h: \"hours\",\n day: \"days\",\n d: \"days\",\n week: \"weeks\",\n w: \"weeks\",\n};\n\n/** Resolve a (possibly aliased / singular) unit token to its ms length. */\nfunction unitToMs(unit: string): number {\n const canonical = UNIT_ALIASES[unit] ?? unit;\n const ms = UNIT_MS[canonical];\n\n if (ms === undefined) {\n throw new DateTimeError(\n `Unknown unit \"${unit}\". Supported units: ${Object.keys(UNIT_MS).join(\", \")}.`,\n { type: \"invalid-unit\" },\n );\n }\n\n return ms;\n}\n\n/**\n * Parse an ISO-8601 instant into a {@link Date}, or throw a typed\n * {@link DateTimeError} when the string is missing or unparseable.\n *\n * @param iso - The ISO string from the model (may be undefined).\n * @param field - The input field name, for the error message.\n */\nfunction parseIso(iso: string | undefined, field: string): Date {\n if (iso === undefined) {\n throw new DateTimeError(`\"${field}\" is required for this operation.`, {\n type: \"invalid-input\",\n });\n }\n\n const date = new Date(iso);\n\n if (Number.isNaN(date.getTime())) {\n throw new DateTimeError(`\"${field}\" is not a valid ISO-8601 instant: \"${iso}\".`, {\n type: \"invalid-input\",\n });\n }\n\n return date;\n}\n\n/**\n * Render a {@link Date} in a target time zone using `Intl`. The\n * `\"iso\"` format (the default) returns the instant's UTC ISO string;\n * any other `format` value is treated as an `Intl.DateTimeFormat`\n * locale-style rendering in the given `timeZone`.\n *\n * @throws DateTimeError `type: \"invalid-time-zone\"` when `timeZone` is\n * not a recognized IANA zone.\n */\nfunction render(date: Date, format: string | undefined, timeZone: string | undefined): string {\n if (format === undefined || format === \"iso\") {\n // `timeZone` is irrelevant to a UTC ISO string, but validate it when\n // supplied so a bad zone is reported rather than silently ignored.\n if (timeZone !== undefined) {\n assertTimeZone(timeZone);\n }\n\n return date.toISOString();\n }\n\n try {\n return new Intl.DateTimeFormat(\"en-US\", {\n timeZone,\n dateStyle: format === \"date\" ? \"medium\" : undefined,\n timeStyle: format === \"time\" ? \"medium\" : undefined,\n ...(format === \"datetime\" ? { dateStyle: \"medium\", timeStyle: \"medium\" } : {}),\n }).format(date);\n } catch (error) {\n throw new DateTimeError(\n `Could not render with format \"${format}\"${\n timeZone ? ` in time zone \"${timeZone}\"` : \"\"\n }.`,\n { type: \"invalid-time-zone\", cause: error },\n );\n }\n}\n\n/**\n * Validate an IANA time zone by attempting to construct a formatter for\n * it; an unrecognized zone makes `Intl` throw a `RangeError`.\n *\n * @throws DateTimeError `type: \"invalid-time-zone\"` for an unknown zone.\n */\nfunction assertTimeZone(timeZone: string): void {\n try {\n new Intl.DateTimeFormat(\"en-US\", { timeZone });\n } catch (error) {\n throw new DateTimeError(`Unknown time zone \"${timeZone}\".`, {\n type: \"invalid-time-zone\",\n cause: error,\n });\n }\n}\n\n/**\n * Build the `date_time` tool — a small clock/calendar utility the agent\n * can call to read the current instant, shift an instant, measure the\n * gap between two instants, or render one. The `op` discriminator selects\n * the operation:\n *\n * - **`now`** — the current instant, rendered per `format` / `timeZone`\n * (defaults to a UTC ISO string). Reads the injectable {@link Clock}.\n * - **`add`** — `iso` shifted by `amount` of `unit` (e.g.\n * `+3 days`); a negative `amount` shifts backward.\n * - **`diff`** — the signed difference `to − iso`, expressed in `unit`.\n * - **`format`** — `iso` rendered per `format` / `timeZone`.\n *\n * Units are millisecond-based (`milliseconds`…`weeks`, plus common\n * aliases); calendar-relative `month`/`year` are intentionally\n * unsupported because they have no fixed length.\n *\n * **Deterministic in tests.** The clock backing `now` is injectable via\n * `deps.clock`; production defaults to `Date.now`.\n *\n * **Errors flow as data.** A missing/invalid field, an unknown unit, or\n * an unrecognized time zone throws a {@link DateTimeError} inside the\n * handler; `tool()` surfaces it in `{ error }` so the agent self-corrects.\n *\n * @param options - Optional overrides; `name` renames the tool,\n * `defaultTimeZone` applies when a call omits `timeZone`.\n * @param deps - Injectable dependencies (the {@link Clock}); defaults to real time.\n * @returns A `ToolContract<DateTimeInput, { value }>` ready for `tools: []`.\n *\n * @example\n * const clock = () => Date.parse(\"2026-06-22T00:00:00Z\");\n * const dt = dateTimeTool({}, { clock });\n * const { data } = await dt.invoke({ op: \"now\" });\n * console.log(data?.value); // \"2026-06-22T00:00:00.000Z\"\n */\nexport function dateTimeTool(\n options?: DateTimeOptions,\n deps?: DateTimeToolDeps,\n): ToolContract<DateTimeInput, DateTimeResult> {\n const clock: Clock = deps?.clock ?? Date.now;\n const defaultTimeZone = options?.defaultTimeZone;\n\n return tool<DateTimeInput, DateTimeResult>({\n name: options?.name ?? DEFAULT_NAME,\n description:\n \"Work with dates and times. Set op to: \\\"now\\\" (current instant), \" +\n '\"add\" (shift iso by amount of unit), \"diff\" (signed to − from in unit; ' +\n \"pass the start instant as iso or from), \" +\n 'or \"format\" (render iso). Units are milliseconds, seconds, minutes, ' +\n \"hours, days, or weeks (month/year are not supported). Pass instants as \" +\n 'ISO-8601 strings; set format to \"iso\" (default), \"date\", \"time\", or ' +\n '\"datetime\", and timeZone as an IANA zone like \"Africa/Cairo\".',\n input: inputSchema,\n async execute(input) {\n const timeZone = input.timeZone ?? defaultTimeZone;\n\n switch (input.op) {\n case \"now\": {\n const date = new Date(clock());\n\n return { value: render(date, input.format, timeZone) };\n }\n\n case \"format\": {\n const date = parseIso(input.iso, \"iso\");\n\n return { value: render(date, input.format, timeZone) };\n }\n\n case \"add\": {\n const date = parseIso(input.iso, \"iso\");\n\n if (input.amount === undefined) {\n throw new DateTimeError('\"amount\" is required for the \"add\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n if (input.unit === undefined) {\n throw new DateTimeError('\"unit\" is required for the \"add\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n const shifted = new Date(date.getTime() + input.amount * unitToMs(input.unit));\n\n return { value: render(shifted, input.format, timeZone) };\n }\n\n case \"diff\": {\n // Accept `from` as an alias for `iso` — models naturally pass\n // `from` / `to` for a difference. `iso` wins when both are set.\n const from = parseIso(input.iso ?? input.from, \"iso (or from)\");\n const to = parseIso(input.to, \"to\");\n\n if (input.unit === undefined) {\n throw new DateTimeError('\"unit\" is required for the \"diff\" operation.', {\n type: \"invalid-input\",\n });\n }\n\n const diff = (to.getTime() - from.getTime()) / unitToMs(input.unit);\n\n return { value: String(diff) };\n }\n\n default: {\n // Exhaustiveness guard — the schema's literal union should make\n // this unreachable, but a future op added to the union without a\n // case here surfaces as typed data rather than silent fallthrough.\n const unreachable: never = input.op;\n\n throw new DateTimeError(`Unsupported operation \"${String(unreachable)}\".`, {\n type: \"unsupported-op\",\n });\n }\n }\n },\n });\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
//#region ../@warlock.js/ai-tools/src/utility/schema.ts
|
|
2
|
+
/**
|
|
3
|
+
* Tiny, dependency-free [Standard Schema](https://standardschema.dev)
|
|
4
|
+
* builders for the utility tools' input validation. This package pins
|
|
5
|
+
* only `@warlock.js/ai` as a runtime peer, so rather than pull in a
|
|
6
|
+
* schema library we hand-roll the few field shapes the calculator and
|
|
7
|
+
* date-time tools need — the same approach `@warlock.js/ai`'s own
|
|
8
|
+
* `tool()` tests use. Each builder returns a {@link StandardSchemaV1},
|
|
9
|
+
* which is what `tool({ input })` validates against before calling
|
|
10
|
+
* `execute`.
|
|
11
|
+
*
|
|
12
|
+
* These intentionally cover only the primitive cases the UTILITY tools
|
|
13
|
+
* require (`string`, `optional string`, `optional number`, and a
|
|
14
|
+
* `literal union` enum). They are not a general-purpose validator.
|
|
15
|
+
*/
|
|
16
|
+
/** The vendor tag stamped on every issue these builders produce. */
|
|
17
|
+
const VENDOR = "ai-tools";
|
|
18
|
+
/** Required string field — rejects anything that is not a string. */
|
|
19
|
+
function stringField() {
|
|
20
|
+
return (value, key) => {
|
|
21
|
+
if (typeof value === "string") return { value };
|
|
22
|
+
return { issues: [{
|
|
23
|
+
message: `"${key}" must be a string`,
|
|
24
|
+
path: [key]
|
|
25
|
+
}] };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Optional string field — accepts `undefined` (the property absent or
|
|
30
|
+
* explicitly undefined) or a string, and rejects every other type.
|
|
31
|
+
*/
|
|
32
|
+
function optionalStringField() {
|
|
33
|
+
return (value, key) => {
|
|
34
|
+
if (value === void 0) return { value: void 0 };
|
|
35
|
+
if (typeof value === "string") return { value };
|
|
36
|
+
return { issues: [{
|
|
37
|
+
message: `"${key}" must be a string when provided`,
|
|
38
|
+
path: [key]
|
|
39
|
+
}] };
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Optional finite-number field — accepts `undefined` or a finite number,
|
|
44
|
+
* rejecting `NaN`/`Infinity` and non-number types.
|
|
45
|
+
*/
|
|
46
|
+
function optionalNumberField() {
|
|
47
|
+
return (value, key) => {
|
|
48
|
+
if (value === void 0) return { value: void 0 };
|
|
49
|
+
if (typeof value === "number" && Number.isFinite(value)) return { value };
|
|
50
|
+
return { issues: [{
|
|
51
|
+
message: `"${key}" must be a finite number when provided`,
|
|
52
|
+
path: [key]
|
|
53
|
+
}] };
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Required literal-union field — accepts only one of `allowed`, rejecting
|
|
58
|
+
* everything else. Used for discriminators like the date-time `op`.
|
|
59
|
+
*
|
|
60
|
+
* @param allowed - The closed set of permitted string literals.
|
|
61
|
+
*/
|
|
62
|
+
function literalField(allowed) {
|
|
63
|
+
return (value, key) => {
|
|
64
|
+
if (typeof value === "string" && allowed.includes(value)) return { value };
|
|
65
|
+
return { issues: [{
|
|
66
|
+
message: `"${key}" must be one of: ${allowed.join(", ")}`,
|
|
67
|
+
path: [key]
|
|
68
|
+
}] };
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Build a {@link StandardSchemaV1} for a flat object whose every property
|
|
73
|
+
* is validated by a {@link FieldValidator}. The input must be a non-null
|
|
74
|
+
* object; each declared field is validated and the (possibly coerced)
|
|
75
|
+
* values are collected into the typed result. All field issues are merged
|
|
76
|
+
* so the caller sees every problem at once.
|
|
77
|
+
*
|
|
78
|
+
* `T` is constrained to `object` rather than `Record<string, unknown>` so
|
|
79
|
+
* the tool IO `interface`s (which carry no implicit string index
|
|
80
|
+
* signature) satisfy it directly — only the declared keys in `shape` are
|
|
81
|
+
* ever read, so a string index signature is never required.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const schema = objectSchema<{ expression: string }>({
|
|
85
|
+
* expression: stringField(),
|
|
86
|
+
* });
|
|
87
|
+
*/
|
|
88
|
+
function objectSchema(shape) {
|
|
89
|
+
return { "~standard": {
|
|
90
|
+
version: 1,
|
|
91
|
+
vendor: VENDOR,
|
|
92
|
+
validate(input) {
|
|
93
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return { issues: [{ message: "input must be an object" }] };
|
|
94
|
+
const source = input;
|
|
95
|
+
const issues = [];
|
|
96
|
+
const result = {};
|
|
97
|
+
for (const key of Object.keys(shape)) {
|
|
98
|
+
const field = shape[key];
|
|
99
|
+
const outcome = field(source[key], key);
|
|
100
|
+
if ("issues" in outcome) {
|
|
101
|
+
issues.push(...outcome.issues);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (outcome.value !== void 0) result[key] = outcome.value;
|
|
105
|
+
}
|
|
106
|
+
if (issues.length > 0) return { issues };
|
|
107
|
+
return { value: result };
|
|
108
|
+
}
|
|
109
|
+
} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
//#endregion
|
|
113
|
+
export { literalField, objectSchema, optionalNumberField, optionalStringField, stringField };
|
|
114
|
+
//# sourceMappingURL=schema.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.mjs","names":[],"sources":["../../../../../../../@warlock.js/ai-tools/src/utility/schema.ts"],"sourcesContent":["import type { StandardSchemaV1 } from \"@standard-schema/spec\";\n\n/**\n * Tiny, dependency-free [Standard Schema](https://standardschema.dev)\n * builders for the utility tools' input validation. This package pins\n * only `@warlock.js/ai` as a runtime peer, so rather than pull in a\n * schema library we hand-roll the few field shapes the calculator and\n * date-time tools need — the same approach `@warlock.js/ai`'s own\n * `tool()` tests use. Each builder returns a {@link StandardSchemaV1},\n * which is what `tool({ input })` validates against before calling\n * `execute`.\n *\n * These intentionally cover only the primitive cases the UTILITY tools\n * require (`string`, `optional string`, `optional number`, and a\n * `literal union` enum). They are not a general-purpose validator.\n */\n\n/** The vendor tag stamped on every issue these builders produce. */\nconst VENDOR = \"ai-tools\";\n\n/**\n * A single field validator inside {@link objectSchema}: given a value,\n * return either the coerced value or a list of issues. Field validators\n * receive the raw property and the property name (for issue messages).\n */\ntype FieldValidator<T> = (\n value: unknown,\n key: string,\n) => { value: T } | { issues: StandardSchemaV1.Issue[] };\n\n/** Required string field — rejects anything that is not a string. */\nexport function stringField(): FieldValidator<string> {\n return (value, key) => {\n if (typeof value === \"string\") {\n return { value };\n }\n\n return { issues: [{ message: `\"${key}\" must be a string`, path: [key] }] };\n };\n}\n\n/**\n * Optional string field — accepts `undefined` (the property absent or\n * explicitly undefined) or a string, and rejects every other type.\n */\nexport function optionalStringField(): FieldValidator<string | undefined> {\n return (value, key) => {\n if (value === undefined) {\n return { value: undefined };\n }\n\n if (typeof value === \"string\") {\n return { value };\n }\n\n return {\n issues: [{ message: `\"${key}\" must be a string when provided`, path: [key] }],\n };\n };\n}\n\n/**\n * Optional finite-number field — accepts `undefined` or a finite number,\n * rejecting `NaN`/`Infinity` and non-number types.\n */\nexport function optionalNumberField(): FieldValidator<number | undefined> {\n return (value, key) => {\n if (value === undefined) {\n return { value: undefined };\n }\n\n if (typeof value === \"number\" && Number.isFinite(value)) {\n return { value };\n }\n\n return {\n issues: [\n { message: `\"${key}\" must be a finite number when provided`, path: [key] },\n ],\n };\n };\n}\n\n/**\n * Required literal-union field — accepts only one of `allowed`, rejecting\n * everything else. Used for discriminators like the date-time `op`.\n *\n * @param allowed - The closed set of permitted string literals.\n */\nexport function literalField<T extends string>(\n allowed: readonly T[],\n): FieldValidator<T> {\n return (value, key) => {\n if (typeof value === \"string\" && (allowed as readonly string[]).includes(value)) {\n return { value: value as T };\n }\n\n return {\n issues: [\n {\n message: `\"${key}\" must be one of: ${allowed.join(\", \")}`,\n path: [key],\n },\n ],\n };\n };\n}\n\n/** The per-key field validator map describing an object schema's shape. */\ntype ObjectShape<T> = {\n [K in keyof T]-?: FieldValidator<T[K]>;\n};\n\n/**\n * Build a {@link StandardSchemaV1} for a flat object whose every property\n * is validated by a {@link FieldValidator}. The input must be a non-null\n * object; each declared field is validated and the (possibly coerced)\n * values are collected into the typed result. All field issues are merged\n * so the caller sees every problem at once.\n *\n * `T` is constrained to `object` rather than `Record<string, unknown>` so\n * the tool IO `interface`s (which carry no implicit string index\n * signature) satisfy it directly — only the declared keys in `shape` are\n * ever read, so a string index signature is never required.\n *\n * @example\n * const schema = objectSchema<{ expression: string }>({\n * expression: stringField(),\n * });\n */\nexport function objectSchema<T extends object>(\n shape: ObjectShape<T>,\n): StandardSchemaV1<T> {\n return {\n \"~standard\": {\n version: 1,\n vendor: VENDOR,\n validate(input) {\n if (typeof input !== \"object\" || input === null || Array.isArray(input)) {\n return { issues: [{ message: \"input must be an object\" }] };\n }\n\n const source = input as Record<string, unknown>;\n const issues: StandardSchemaV1.Issue[] = [];\n const result: Record<string, unknown> = {};\n\n for (const key of Object.keys(shape) as (keyof T)[]) {\n const field = shape[key];\n const outcome = field(source[key as string], key as string);\n\n if (\"issues\" in outcome) {\n issues.push(...outcome.issues);\n\n continue;\n }\n\n // Only carry through keys that resolved to a defined value, so\n // optional-absent fields stay absent rather than becoming\n // explicit `undefined` properties.\n if (outcome.value !== undefined) {\n result[key as string] = outcome.value;\n }\n }\n\n if (issues.length > 0) {\n return { issues };\n }\n\n return { value: result as T };\n },\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,MAAM,SAAS;;AAaf,SAAgB,cAAsC;CACpD,QAAQ,OAAO,QAAQ;EACrB,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,MAAM;EAGjB,OAAO,EAAE,QAAQ,CAAC;GAAE,SAAS,IAAI,IAAI;GAAqB,MAAM,CAAC,GAAG;EAAE,CAAC,EAAE;CAC3E;AACF;;;;;AAMA,SAAgB,sBAA0D;CACxE,QAAQ,OAAO,QAAQ;EACrB,IAAI,UAAU,QACZ,OAAO,EAAE,OAAO,OAAU;EAG5B,IAAI,OAAO,UAAU,UACnB,OAAO,EAAE,MAAM;EAGjB,OAAO,EACL,QAAQ,CAAC;GAAE,SAAS,IAAI,IAAI;GAAmC,MAAM,CAAC,GAAG;EAAE,CAAC,EAC9E;CACF;AACF;;;;;AAMA,SAAgB,sBAA0D;CACxE,QAAQ,OAAO,QAAQ;EACrB,IAAI,UAAU,QACZ,OAAO,EAAE,OAAO,OAAU;EAG5B,IAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,GACpD,OAAO,EAAE,MAAM;EAGjB,OAAO,EACL,QAAQ,CACN;GAAE,SAAS,IAAI,IAAI;GAA0C,MAAM,CAAC,GAAG;EAAE,CAC3E,EACF;CACF;AACF;;;;;;;AAQA,SAAgB,aACd,SACmB;CACnB,QAAQ,OAAO,QAAQ;EACrB,IAAI,OAAO,UAAU,YAAa,QAA8B,SAAS,KAAK,GAC5E,OAAO,EAAS,MAAW;EAG7B,OAAO,EACL,QAAQ,CACN;GACE,SAAS,IAAI,IAAI,oBAAoB,QAAQ,KAAK,IAAI;GACtD,MAAM,CAAC,GAAG;EACZ,CACF,EACF;CACF;AACF;;;;;;;;;;;;;;;;;;AAwBA,SAAgB,aACd,OACqB;CACrB,OAAO,EACL,aAAa;EACX,SAAS;EACT,QAAQ;EACR,SAAS,OAAO;GACd,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,KAAK,GACpE,OAAO,EAAE,QAAQ,CAAC,EAAE,SAAS,0BAA0B,CAAC,EAAE;GAG5D,MAAM,SAAS;GACf,MAAM,SAAmC,CAAC;GAC1C,MAAM,SAAkC,CAAC;GAEzC,KAAK,MAAM,OAAO,OAAO,KAAK,KAAK,GAAkB;IACnD,MAAM,QAAQ,MAAM;IACpB,MAAM,UAAU,MAAM,OAAO,MAAgB,GAAa;IAE1D,IAAI,YAAY,SAAS;KACvB,OAAO,KAAK,GAAG,QAAQ,MAAM;KAE7B;IACF;IAKA,IAAI,QAAQ,UAAU,QACpB,OAAO,OAAiB,QAAQ;GAEpC;GAEA,IAAI,OAAO,SAAS,GAClB,OAAO,EAAE,OAAO;GAGlB,OAAO,EAAE,OAAO,OAAY;EAC9B;CACF,EACF;AACF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { FetchUrlInput, FetchUrlOptions, FetchUrlResult } from "../contracts/web.type.mjs";
|
|
2
|
+
import { ToolContract } from "@warlock.js/ai";
|
|
3
|
+
|
|
4
|
+
//#region ../@warlock.js/ai-tools/src/web/fetch-url.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Build the agent-facing `fetch_url` tool — fetch a URL over the global
|
|
7
|
+
* `fetch` (Node 18+) and hand the model back rendered `content`.
|
|
8
|
+
*
|
|
9
|
+
* Guardrails, applied in order before/around the network call:
|
|
10
|
+
* - **`allowHosts`** — when set, a request to any host not in the list is
|
|
11
|
+
* rejected before the fetch (an SSRF guardrail).
|
|
12
|
+
* - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`
|
|
13
|
+
* (default {@link DEFAULT_TIMEOUT_MS}).
|
|
14
|
+
* - **`maxBytes`** — the response body is read up to this cap and the
|
|
15
|
+
* result flags `truncated` when it was cut off (default
|
|
16
|
+
* {@link DEFAULT_MAX_BYTES}).
|
|
17
|
+
* - **`extract`** — `"html"` returns the raw body; `"text"` (default) and
|
|
18
|
+
* `"markdown"` run `@mozilla/readability` over `jsdom`, lazily imported
|
|
19
|
+
* so they stay OPTIONAL peers — a missing peer throws the curated
|
|
20
|
+
* install string.
|
|
21
|
+
*
|
|
22
|
+
* **Errors flow as data.** Every guardrail rejection and network failure
|
|
23
|
+
* throws a typed {@link WebToolError}; the `tool()` wrapper catches it and
|
|
24
|
+
* surfaces it in the returned `{ error }` field — `invoke()` never throws
|
|
25
|
+
* — so the agent reads the failure and self-corrects.
|
|
26
|
+
*
|
|
27
|
+
* @param options - Tool-name override, byte cap, timeout, extract mode,
|
|
28
|
+
* and host allowlist.
|
|
29
|
+
* @returns A `ToolContract<{ url }, FetchUrlResult>`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* const fetchUrl = fetchUrlTool({ extract: "text", allowHosts: ["docs.stripe.com"] });
|
|
33
|
+
* const { data } = await fetchUrl.invoke({ url: "https://docs.stripe.com/api" });
|
|
34
|
+
* console.log(data?.content, data?.truncated);
|
|
35
|
+
*/
|
|
36
|
+
declare function fetchUrlTool(options?: FetchUrlOptions): ToolContract<FetchUrlInput, FetchUrlResult>;
|
|
37
|
+
//#endregion
|
|
38
|
+
export { fetchUrlTool };
|
|
39
|
+
//# sourceMappingURL=fetch-url.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch-url.d.mts","names":[],"sources":["../../../../../../../@warlock.js/ai-tools/src/web/fetch-url.ts"],"mappings":";;;;;;;AA6SA;;;;;;;;;;;;;;;AAE6C;;;;;;;;;;;;;iBAF7B,YAAA,CACd,OAAA,GAAU,eAAA,GACT,YAAA,CAAa,aAAA,EAAe,cAAA"}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { WebToolError } from "../errors.mjs";
|
|
2
|
+
import { objectSchema, stringField } from "./schema.mjs";
|
|
3
|
+
import { tool } from "@warlock.js/ai";
|
|
4
|
+
|
|
5
|
+
//#region ../@warlock.js/ai-tools/src/web/fetch-url.ts
|
|
6
|
+
/** Default tool name exposed to the LLM. */
|
|
7
|
+
const DEFAULT_NAME = "fetch_url";
|
|
8
|
+
/** Default hard cap on response bytes read before truncation. */
|
|
9
|
+
const DEFAULT_MAX_BYTES = 1e6;
|
|
10
|
+
/** Default per-request timeout in milliseconds. */
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 15e3;
|
|
12
|
+
/** Default rendering mode for the fetched document. */
|
|
13
|
+
const DEFAULT_EXTRACT = "text";
|
|
14
|
+
/** Input schema: `{ url }`. */
|
|
15
|
+
const inputSchema = objectSchema({ url: stringField() });
|
|
16
|
+
const READABILITY_MODULE_ID = "@mozilla/readability";
|
|
17
|
+
const JSDOM_MODULE_ID = "jsdom";
|
|
18
|
+
let ReadabilitySdk;
|
|
19
|
+
let JsdomSdk;
|
|
20
|
+
let isReadabilityAvailable;
|
|
21
|
+
let loadingPromise;
|
|
22
|
+
const READABILITY_INSTALL_INSTRUCTIONS = `
|
|
23
|
+
The fetch_url text/markdown extractor requires the @mozilla/readability and jsdom packages.
|
|
24
|
+
Install them with:
|
|
25
|
+
|
|
26
|
+
npm install @mozilla/readability jsdom
|
|
27
|
+
|
|
28
|
+
Or with your preferred package manager:
|
|
29
|
+
|
|
30
|
+
pnpm add @mozilla/readability jsdom
|
|
31
|
+
yarn add @mozilla/readability jsdom
|
|
32
|
+
`.trim();
|
|
33
|
+
/**
|
|
34
|
+
* Settle the lazy import of `@mozilla/readability` + `jsdom` once,
|
|
35
|
+
* concurrency-safe. Only needed for the `"text"` / `"markdown"` extract
|
|
36
|
+
* modes — `"html"` returns the raw body and never loads them. A bare
|
|
37
|
+
* `catch` flips the flag to `false`; the curated install string surfaces
|
|
38
|
+
* at use time via {@link WebToolError}, never a raw module-resolution
|
|
39
|
+
* stack trace.
|
|
40
|
+
*/
|
|
41
|
+
function loadReadability() {
|
|
42
|
+
if (isReadabilityAvailable !== void 0) return Promise.resolve();
|
|
43
|
+
if (loadingPromise) return loadingPromise;
|
|
44
|
+
loadingPromise = (async () => {
|
|
45
|
+
try {
|
|
46
|
+
ReadabilitySdk = await import(READABILITY_MODULE_ID);
|
|
47
|
+
JsdomSdk = await import(JSDOM_MODULE_ID);
|
|
48
|
+
isReadabilityAvailable = true;
|
|
49
|
+
} catch {
|
|
50
|
+
isReadabilityAvailable = false;
|
|
51
|
+
}
|
|
52
|
+
})();
|
|
53
|
+
return loadingPromise;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse the input URL, rejecting anything unparseable or not over
|
|
57
|
+
* `http`/`https` with a typed {@link WebToolError}.
|
|
58
|
+
*/
|
|
59
|
+
function parseUrl(raw) {
|
|
60
|
+
let url;
|
|
61
|
+
try {
|
|
62
|
+
url = new URL(raw);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new WebToolError(`fetch_url received an unparseable URL: "${raw}".`, { type: "invalid-url" });
|
|
65
|
+
}
|
|
66
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new WebToolError(`fetch_url only supports http(s) URLs; got "${url.protocol}".`, { type: "invalid-url" });
|
|
67
|
+
return url;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Enforce the host allowlist (an SSRF guardrail) before any network call.
|
|
71
|
+
* When `allowHosts` is set, a request to a host not in it is rejected;
|
|
72
|
+
* matching is exact on the URL hostname (case-insensitive).
|
|
73
|
+
*/
|
|
74
|
+
function assertHostAllowed(url, allowHosts) {
|
|
75
|
+
if (!allowHosts || allowHosts.length === 0) return;
|
|
76
|
+
const host = url.hostname.toLowerCase();
|
|
77
|
+
if (!allowHosts.some((entry) => entry.toLowerCase() === host)) throw new WebToolError(`fetch_url blocked host "${url.hostname}" — it is not in the allowHosts allowlist.`, {
|
|
78
|
+
type: "denied-host",
|
|
79
|
+
context: { host: url.hostname }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read the response body up to `maxBytes`, stopping early once the cap is
|
|
84
|
+
* reached. Returns the decoded text and whether it was truncated. When
|
|
85
|
+
* the body has no stream (a stubbed `Response`), falls back to `.text()`
|
|
86
|
+
* and truncates the decoded string at `maxBytes`.
|
|
87
|
+
*/
|
|
88
|
+
async function readBody(response, maxBytes) {
|
|
89
|
+
const stream = response.body;
|
|
90
|
+
if (!stream) {
|
|
91
|
+
const text = await response.text();
|
|
92
|
+
if (text.length > maxBytes) return {
|
|
93
|
+
body: text.slice(0, maxBytes),
|
|
94
|
+
truncated: true
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
body: text,
|
|
98
|
+
truncated: false
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const reader = stream.getReader();
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let received = 0;
|
|
104
|
+
let truncated = false;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (done) break;
|
|
108
|
+
if (value) {
|
|
109
|
+
received += value.byteLength;
|
|
110
|
+
chunks.push(value);
|
|
111
|
+
if (received >= maxBytes) {
|
|
112
|
+
truncated = true;
|
|
113
|
+
await reader.cancel();
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const merged = new Uint8Array(received);
|
|
119
|
+
let offset = 0;
|
|
120
|
+
for (const chunk of chunks) {
|
|
121
|
+
merged.set(chunk, offset);
|
|
122
|
+
offset += chunk.byteLength;
|
|
123
|
+
}
|
|
124
|
+
const sliced = truncated ? merged.subarray(0, maxBytes) : merged;
|
|
125
|
+
return {
|
|
126
|
+
body: new TextDecoder().decode(sliced),
|
|
127
|
+
truncated
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Render the fetched HTML into the configured `extract` mode. `"html"`
|
|
132
|
+
* returns the raw body untouched; `"text"` and `"markdown"` run
|
|
133
|
+
* `@mozilla/readability` over a `jsdom` document, throwing a curated
|
|
134
|
+
* {@link WebToolError} when those optional peers are absent. `"markdown"`
|
|
135
|
+
* applies a light heading/paragraph conversion over the extracted text.
|
|
136
|
+
*/
|
|
137
|
+
async function render(html, url, extract) {
|
|
138
|
+
if (extract === "html") return html;
|
|
139
|
+
await loadReadability();
|
|
140
|
+
if (!isReadabilityAvailable) throw new WebToolError(READABILITY_INSTALL_INSTRUCTIONS, { type: "missing-peer" });
|
|
141
|
+
const dom = new JsdomSdk.JSDOM(html, { url });
|
|
142
|
+
const article = new ReadabilitySdk.Readability(dom.window.document).parse();
|
|
143
|
+
if (extract === "markdown") return article?.content ? htmlToMarkdown(article.content) : article?.textContent ?? "";
|
|
144
|
+
return article?.textContent ?? "";
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Minimal HTML→Markdown reduction for readability's extracted article
|
|
148
|
+
* HTML — headings become `#` prefixes, paragraphs/line-breaks become
|
|
149
|
+
* blank-line separated blocks, and remaining tags are stripped. This is a
|
|
150
|
+
* pragmatic conversion, not a full CommonMark serializer.
|
|
151
|
+
*/
|
|
152
|
+
function htmlToMarkdown(html) {
|
|
153
|
+
return html.replace(/<h([1-6])[^>]*>(.*?)<\/h\1>/gis, (_match, level, text) => {
|
|
154
|
+
return `\n\n${"#".repeat(Number(level))} ${stripTags(text).trim()}\n\n`;
|
|
155
|
+
}).replace(/<\/(p|div|section|article|li)>/gi, "\n\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
156
|
+
}
|
|
157
|
+
/** Strip any remaining HTML tags from a fragment. */
|
|
158
|
+
function stripTags(html) {
|
|
159
|
+
return html.replace(/<[^>]+>/g, "");
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build the agent-facing `fetch_url` tool — fetch a URL over the global
|
|
163
|
+
* `fetch` (Node 18+) and hand the model back rendered `content`.
|
|
164
|
+
*
|
|
165
|
+
* Guardrails, applied in order before/around the network call:
|
|
166
|
+
* - **`allowHosts`** — when set, a request to any host not in the list is
|
|
167
|
+
* rejected before the fetch (an SSRF guardrail).
|
|
168
|
+
* - **`timeoutMs`** — the request is aborted via `AbortSignal.timeout`
|
|
169
|
+
* (default {@link DEFAULT_TIMEOUT_MS}).
|
|
170
|
+
* - **`maxBytes`** — the response body is read up to this cap and the
|
|
171
|
+
* result flags `truncated` when it was cut off (default
|
|
172
|
+
* {@link DEFAULT_MAX_BYTES}).
|
|
173
|
+
* - **`extract`** — `"html"` returns the raw body; `"text"` (default) and
|
|
174
|
+
* `"markdown"` run `@mozilla/readability` over `jsdom`, lazily imported
|
|
175
|
+
* so they stay OPTIONAL peers — a missing peer throws the curated
|
|
176
|
+
* install string.
|
|
177
|
+
*
|
|
178
|
+
* **Errors flow as data.** Every guardrail rejection and network failure
|
|
179
|
+
* throws a typed {@link WebToolError}; the `tool()` wrapper catches it and
|
|
180
|
+
* surfaces it in the returned `{ error }` field — `invoke()` never throws
|
|
181
|
+
* — so the agent reads the failure and self-corrects.
|
|
182
|
+
*
|
|
183
|
+
* @param options - Tool-name override, byte cap, timeout, extract mode,
|
|
184
|
+
* and host allowlist.
|
|
185
|
+
* @returns A `ToolContract<{ url }, FetchUrlResult>`.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* const fetchUrl = fetchUrlTool({ extract: "text", allowHosts: ["docs.stripe.com"] });
|
|
189
|
+
* const { data } = await fetchUrl.invoke({ url: "https://docs.stripe.com/api" });
|
|
190
|
+
* console.log(data?.content, data?.truncated);
|
|
191
|
+
*/
|
|
192
|
+
function fetchUrlTool(options) {
|
|
193
|
+
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
194
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
195
|
+
const extract = options?.extract ?? DEFAULT_EXTRACT;
|
|
196
|
+
const allowHosts = options?.allowHosts;
|
|
197
|
+
if (extract !== "html") loadReadability();
|
|
198
|
+
return tool({
|
|
199
|
+
name: options?.name ?? DEFAULT_NAME,
|
|
200
|
+
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.",
|
|
201
|
+
input: inputSchema,
|
|
202
|
+
async execute(input) {
|
|
203
|
+
const url = parseUrl(input.url);
|
|
204
|
+
assertHostAllowed(url, allowHosts);
|
|
205
|
+
let response;
|
|
206
|
+
try {
|
|
207
|
+
response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
throw new WebToolError(`fetch_url request failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
|
|
210
|
+
type: "request-failed",
|
|
211
|
+
cause
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const { body, truncated } = await readBody(response, maxBytes);
|
|
215
|
+
const content = await render(body, response.url || url.toString(), extract);
|
|
216
|
+
return {
|
|
217
|
+
url: response.url || url.toString(),
|
|
218
|
+
status: response.status,
|
|
219
|
+
content,
|
|
220
|
+
truncated
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
//#endregion
|
|
227
|
+
export { fetchUrlTool };
|
|
228
|
+
//# sourceMappingURL=fetch-url.mjs.map
|