@cerebro-labs/agentnava-sdk 0.26.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/LICENSE +202 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/agent.d.ts +285 -0
- package/agent.js +394 -0
- package/agent.js.map +1 -0
- package/attach.d.ts +49 -0
- package/attach.js +115 -0
- package/attach.js.map +1 -0
- package/connection-status.d.ts +28 -0
- package/connection-status.js +61 -0
- package/connection-status.js.map +1 -0
- package/connection.d.ts +89 -0
- package/connection.js +143 -0
- package/connection.js.map +1 -0
- package/conversation.d.ts +177 -0
- package/conversation.js +348 -0
- package/conversation.js.map +1 -0
- package/end-user.d.ts +159 -0
- package/end-user.js +209 -0
- package/end-user.js.map +1 -0
- package/errors.d.ts +43 -0
- package/errors.js +55 -0
- package/errors.js.map +1 -0
- package/http.d.ts +32 -0
- package/http.js +192 -0
- package/http.js.map +1 -0
- package/import-tools.d.ts +282 -0
- package/import-tools.js +686 -0
- package/import-tools.js.map +1 -0
- package/index.d.ts +16 -0
- package/index.js +38 -0
- package/index.js.map +1 -0
- package/knowledge-base.d.ts +165 -0
- package/knowledge-base.js +274 -0
- package/knowledge-base.js.map +1 -0
- package/load-agent-dir.d.ts +17 -0
- package/load-agent-dir.js +192 -0
- package/load-agent-dir.js.map +1 -0
- package/package.json +32 -0
- package/project.d.ts +94 -0
- package/project.js +155 -0
- package/project.js.map +1 -0
- package/secret.d.ts +45 -0
- package/secret.js +69 -0
- package/secret.js.map +1 -0
- package/templates-inline.d.ts +2 -0
- package/templates-inline.js +16 -0
- package/templates-inline.js.map +1 -0
- package/templates.d.ts +16 -0
- package/templates.js +32 -0
- package/templates.js.map +1 -0
- package/turn.d.ts +11 -0
- package/turn.js +244 -0
- package/turn.js.map +1 -0
- package/types.d.ts +485 -0
- package/types.js +4 -0
- package/types.js.map +1 -0
- package/version.d.ts +1 -0
- package/version.js +5 -0
- package/version.js.map +1 -0
- package/workflows.d.ts +36 -0
- package/workflows.js +101 -0
- package/workflows.js.map +1 -0
- package/workspace.d.ts +290 -0
- package/workspace.js +390 -0
- package/workspace.js.map +1 -0
package/import-tools.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
// Turn an API you already describe somewhere into tools an agent can call.
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS IS FOR. A company adopting AgentNava has APIs already, and their
|
|
4
|
+
// team already keeps a Postman collection, an OpenAPI document, or a handful of
|
|
5
|
+
// curl commands that work. Asking them to hand-write a tool declaration per
|
|
6
|
+
// endpoint is asking for a week of somebody's time, and asking them to produce
|
|
7
|
+
// an artifact they do not already have is worse: it is a reason to stall.
|
|
8
|
+
//
|
|
9
|
+
// So this takes whichever of those three they have and gives back draft
|
|
10
|
+
// `httpTools`. Self-serve, in the SDK, no support ticket.
|
|
11
|
+
//
|
|
12
|
+
// IT RUNS ENTIRELY ON YOUR MACHINE. Nothing here makes a network request except
|
|
13
|
+
// `fromOpenApi` when you hand it a URL instead of a document, and that fetch
|
|
14
|
+
// goes to YOUR server, not ours. That is a deliberate security property rather
|
|
15
|
+
// than an implementation convenience: a Postman export routinely contains a live
|
|
16
|
+
// token, and a collection you never upload is a token we can never receive.
|
|
17
|
+
//
|
|
18
|
+
// WHAT IT DOES NOT DO, and this is the honest part:
|
|
19
|
+
//
|
|
20
|
+
// IT CANNOT WRITE THE DESCRIPTIONS. A tool's `description` is what the model
|
|
21
|
+
// reads to decide "this is the tool for this job", and none of these three
|
|
22
|
+
// formats contains that. Their summaries are written for a developer reading
|
|
23
|
+
// documentation, not for a model choosing between forty tools. "Fetch one
|
|
24
|
+
// order by its number, before deciding a refund" is what works; "Orders
|
|
25
|
+
// endpoint" is not. Where the source has nothing useful, the tool comes back
|
|
26
|
+
// with a warning rather than a confident-looking stub, because a bad
|
|
27
|
+
// description does not fail: it quietly makes the agent pick the wrong tool.
|
|
28
|
+
//
|
|
29
|
+
// IT DOES NOT CREATE ANYTHING. It returns drafts. You read them, fix the
|
|
30
|
+
// descriptions, drop the endpoints an agent has no business calling, and pass
|
|
31
|
+
// what is left to `ws.agents.create({ httpTools })`.
|
|
32
|
+
//
|
|
33
|
+
// ```ts
|
|
34
|
+
// import { fromPostman } from '@cerebro-labs/agentnava-sdk';
|
|
35
|
+
//
|
|
36
|
+
// const { tools, secrets, warnings } = fromPostman(collection);
|
|
37
|
+
// for (const w of warnings) console.warn(w.message);
|
|
38
|
+
// for (const s of secrets) await ws.secrets.create({ name: s.name, value: '...' });
|
|
39
|
+
//
|
|
40
|
+
// await ws.agents.create({ id: 'support', instructions: '...', httpTools: tools });
|
|
41
|
+
// ```
|
|
42
|
+
const DEFAULT_WARN_ABOVE = 15;
|
|
43
|
+
/** Header names whose value is a credential whatever it looks like.
|
|
44
|
+
*
|
|
45
|
+
* MATCHED ON THE NAME, NOT THE VALUE, and that is the safe direction. A value
|
|
46
|
+
* test has to guess, and the cost of guessing wrong is a live token written
|
|
47
|
+
* into an agent version, which is readable. A name test over-collects at worst:
|
|
48
|
+
* it turns something harmless into a secret, which is an inconvenience. */
|
|
49
|
+
const CREDENTIAL_HEADERS = new Set([
|
|
50
|
+
'authorization',
|
|
51
|
+
'proxy-authorization',
|
|
52
|
+
'cookie',
|
|
53
|
+
'x-api-key',
|
|
54
|
+
'api-key',
|
|
55
|
+
'apikey',
|
|
56
|
+
'x-auth-token',
|
|
57
|
+
'auth-token',
|
|
58
|
+
'x-access-token',
|
|
59
|
+
'access-token',
|
|
60
|
+
'token',
|
|
61
|
+
'x-session-token',
|
|
62
|
+
'private-token',
|
|
63
|
+
'x-secret',
|
|
64
|
+
]);
|
|
65
|
+
/** Shapes that are a credential wherever they appear, including in a URL.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately narrow: each is a published prefix from a real provider, so a
|
|
68
|
+
* match is near-certain rather than a heuristic. The header rule above is what
|
|
69
|
+
* does the heavy lifting. */
|
|
70
|
+
const CREDENTIAL_VALUE_RE = /\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[A-Za-z0-9_-]{30,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.)/;
|
|
71
|
+
/** A tool name the API will accept: `^[a-z][a-z0-9_]{0,63}$`. */
|
|
72
|
+
export function toolNameFrom(raw, fallback = 'call') {
|
|
73
|
+
const slug = raw
|
|
74
|
+
.trim()
|
|
75
|
+
.toLowerCase()
|
|
76
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
77
|
+
.replace(/^_+|_+$/g, '')
|
|
78
|
+
.slice(0, 64);
|
|
79
|
+
if (slug === '' || !/^[a-z]/.test(slug)) {
|
|
80
|
+
const prefixed = `${fallback}_${slug}`.replace(/_+$/, '').slice(0, 64);
|
|
81
|
+
return /^[a-z][a-z0-9_]*$/.test(prefixed) ? prefixed : fallback;
|
|
82
|
+
}
|
|
83
|
+
return slug;
|
|
84
|
+
}
|
|
85
|
+
/** A param name the API will accept: `^[A-Za-z][A-Za-z0-9_]*$`.
|
|
86
|
+
*
|
|
87
|
+
* THE WIRE NAME IS NOT THE PARAM NAME, and conflating them is what broke this.
|
|
88
|
+
* An API's query key is whatever the API says it is (`page-size`,
|
|
89
|
+
* `filter[status]`), while a param name is an identifier both our validator and
|
|
90
|
+
* the runtime's placeholder regex constrain to `[A-Za-z][A-Za-z0-9_]*`. Copying
|
|
91
|
+
* the wire name into the param name produced `400 params has invalid key
|
|
92
|
+
* "page-size"` on documents that are completely ordinary, which is exactly the
|
|
93
|
+
* unactionable 400 `uniqueName` exists to avoid. So the key on the wire keeps
|
|
94
|
+
* its spelling and only the identifier is normalised. */
|
|
95
|
+
export function paramNameFrom(raw, fallback = 'arg') {
|
|
96
|
+
const slug = raw
|
|
97
|
+
.trim()
|
|
98
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
99
|
+
.replace(/^_+|_+$/g, '')
|
|
100
|
+
.slice(0, 64);
|
|
101
|
+
if (slug === '' || !/^[A-Za-z]/.test(slug)) {
|
|
102
|
+
const prefixed = `${fallback}_${slug}`.replace(/_+$/, '').slice(0, 64);
|
|
103
|
+
return /^[A-Za-z][A-Za-z0-9_]*$/.test(prefixed) ? prefixed : fallback;
|
|
104
|
+
}
|
|
105
|
+
return slug;
|
|
106
|
+
}
|
|
107
|
+
/** A secret name the runtime's placeholder regex will match.
|
|
108
|
+
*
|
|
109
|
+
* `[A-Za-z_][A-Za-z0-9_]*`, so a leading digit is the case that matters. A
|
|
110
|
+
* Postman collection with a `{{2fa_token}}` variable produced
|
|
111
|
+
* `{{secrets.2FA_TOKEN}}`, which that regex does not match at all, so nothing
|
|
112
|
+
* substituted it and the LITERAL braces went out as the header value. No error,
|
|
113
|
+
* just a 401 from the far end. A name that cannot resolve is worse than an ugly
|
|
114
|
+
* one. */
|
|
115
|
+
function normalizeSecretName(raw, fallback = 'API_TOKEN') {
|
|
116
|
+
const slug = raw
|
|
117
|
+
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
118
|
+
.replace(/^_+|_+$/g, '')
|
|
119
|
+
.toUpperCase()
|
|
120
|
+
.slice(0, 64);
|
|
121
|
+
if (slug === '')
|
|
122
|
+
return fallback;
|
|
123
|
+
return /^[A-Za-z_]/.test(slug) ? slug : `S_${slug}`.slice(0, 64);
|
|
124
|
+
}
|
|
125
|
+
/** A secret name from a header, or the caller's own choice. */
|
|
126
|
+
function secretNameFor(header, options) {
|
|
127
|
+
if (options.secretName)
|
|
128
|
+
return options.secretName;
|
|
129
|
+
const h = header.toLowerCase();
|
|
130
|
+
if (h === 'authorization')
|
|
131
|
+
return 'API_TOKEN';
|
|
132
|
+
if (h === 'x-api-key' || h === 'api-key' || h === 'apikey')
|
|
133
|
+
return 'API_KEY';
|
|
134
|
+
return normalizeSecretName(header);
|
|
135
|
+
}
|
|
136
|
+
/** Accumulates tools, secrets and warnings, and keeps names unique.
|
|
137
|
+
*
|
|
138
|
+
* UNIQUE BECAUSE THE API NOW REFUSES DUPLICATES. Two endpoints called "Get" in
|
|
139
|
+
* different folders both slug to `get`, and a duplicate name makes which
|
|
140
|
+
* request goes out a matter of list order: the agent looks like it called the
|
|
141
|
+
* right tool and returns the wrong answer. Renaming here, with a warning, beats
|
|
142
|
+
* a 400 the importer's caller cannot act on. */
|
|
143
|
+
class Draft {
|
|
144
|
+
tools = [];
|
|
145
|
+
warnings = [];
|
|
146
|
+
#secrets = new Map();
|
|
147
|
+
#names = new Set();
|
|
148
|
+
/** `<tool>\0<wire name>` to the param name chosen for it. */
|
|
149
|
+
#paramNames = new Map();
|
|
150
|
+
uniqueName(wanted) {
|
|
151
|
+
if (!this.#names.has(wanted)) {
|
|
152
|
+
this.#names.add(wanted);
|
|
153
|
+
return wanted;
|
|
154
|
+
}
|
|
155
|
+
for (let i = 2; i < 1000; i++) {
|
|
156
|
+
const candidate = `${wanted}_${i}`.slice(0, 64);
|
|
157
|
+
if (!this.#names.has(candidate)) {
|
|
158
|
+
this.#names.add(candidate);
|
|
159
|
+
this.warnings.push({
|
|
160
|
+
kind: 'name-collision',
|
|
161
|
+
tool: candidate,
|
|
162
|
+
message: `Two operations both became "${wanted}"; this one is "${candidate}". Give it a name that says what it does.`,
|
|
163
|
+
});
|
|
164
|
+
return candidate;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`cannot find a free name for ${wanted}`);
|
|
168
|
+
}
|
|
169
|
+
/** Records the secret. Returns true the FIRST time this name is seen, so the
|
|
170
|
+
* caller can warn once rather than once per tool that uses it. */
|
|
171
|
+
secret(name, tool, from) {
|
|
172
|
+
const existing = this.#secrets.get(name);
|
|
173
|
+
if (existing) {
|
|
174
|
+
if (!existing.usedBy.includes(tool))
|
|
175
|
+
existing.usedBy.push(tool);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
this.#secrets.set(name, { name, usedBy: [tool], from });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/** Every secret this tool references, for the tool's own `secrets` field.
|
|
182
|
+
*
|
|
183
|
+
* LOAD-BEARING, NOT DECORATION. `collectSecretNames` in the control plane
|
|
184
|
+
* builds a turn's secret map from each httpTool's `secrets` array; unlike
|
|
185
|
+
* MCP servers it does NOT scan the tool's headers for {{secrets.X}}. A tool
|
|
186
|
+
* that carries the placeholder but not the declaration therefore fails every
|
|
187
|
+
* call with "missing required secret", which is what every drafted tool did
|
|
188
|
+
* until this existed. */
|
|
189
|
+
secretsFor(tool) {
|
|
190
|
+
return [...this.#secrets.values()].filter((s) => s.usedBy.includes(tool)).map((s) => s.name);
|
|
191
|
+
}
|
|
192
|
+
/** Declare a param under a name the API accepts, warning when it had to
|
|
193
|
+
* change, and return the name to use in the placeholder.
|
|
194
|
+
*
|
|
195
|
+
* ONE WIRE NAME IS ONE PARAM, however many times it is mentioned. An
|
|
196
|
+
* OpenAPI operation names a path variable twice: once in the path template
|
|
197
|
+
* `/orders/{order-id}` and once in `parameters`. Treating the second sight
|
|
198
|
+
* of it as a collision produced `order_id` in the url and a second,
|
|
199
|
+
* never-sent `order_id_2` in `params`, which is the very "declared and never
|
|
200
|
+
* sent" defect this class is meant to prevent. So a repeat returns the same
|
|
201
|
+
* name and merges the richer definition over the first guess. */
|
|
202
|
+
param(params, wireName, def, tool) {
|
|
203
|
+
const key = `${tool}\u0000${wireName}`;
|
|
204
|
+
const already = this.#paramNames.get(key);
|
|
205
|
+
if (already !== undefined) {
|
|
206
|
+
params[already] = { ...params[already], ...def };
|
|
207
|
+
return already;
|
|
208
|
+
}
|
|
209
|
+
const wanted = paramNameFrom(wireName);
|
|
210
|
+
let name = wanted;
|
|
211
|
+
for (let i = 2; i < 1000 && name in params; i++)
|
|
212
|
+
name = `${wanted}_${i}`.slice(0, 64);
|
|
213
|
+
this.#paramNames.set(key, name);
|
|
214
|
+
if (name !== wireName) {
|
|
215
|
+
this.warnings.push({
|
|
216
|
+
kind: 'param-renamed',
|
|
217
|
+
tool,
|
|
218
|
+
message: `Parameter "${wireName}" is called "${name}" on the tool, because a param name has ` +
|
|
219
|
+
`to be a plain identifier. The request still sends "${wireName}" on the wire.`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
params[name] = def;
|
|
223
|
+
return name;
|
|
224
|
+
}
|
|
225
|
+
finish(options) {
|
|
226
|
+
const limit = options.warnAbove ?? DEFAULT_WARN_ABOVE;
|
|
227
|
+
if (this.tools.length > limit) {
|
|
228
|
+
this.warnings.push({
|
|
229
|
+
kind: 'too-many-tools',
|
|
230
|
+
message: `${this.tools.length} tools. A model chooses markedly worse between forty than ` +
|
|
231
|
+
`between eight, so keep the operations an agent genuinely needs and drop the rest ` +
|
|
232
|
+
`(pass \`include\` to filter during the import).`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return { tools: this.tools, secrets: [...this.#secrets.values()], warnings: this.warnings };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Headers with every credential replaced by a secret placeholder. */
|
|
239
|
+
function safeHeaders(raw, toolName, draft, options, source) {
|
|
240
|
+
const out = {};
|
|
241
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
242
|
+
const isCredentialHeader = CREDENTIAL_HEADERS.has(name.toLowerCase());
|
|
243
|
+
const looksLikeOne = CREDENTIAL_VALUE_RE.test(value);
|
|
244
|
+
if (!isCredentialHeader && !looksLikeOne) {
|
|
245
|
+
out[name] = value;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const secret = secretNameFor(name, options);
|
|
249
|
+
// The SCHEME is preserved, the credential is not. `Bearer {{secrets.X}}`
|
|
250
|
+
// works; `{{secrets.X}}` alone silently drops the scheme and the server
|
|
251
|
+
// answers 401 about a malformed header.
|
|
252
|
+
const scheme = /^(Bearer|Basic|Token|ApiKey)\s+/i.exec(value)?.[1];
|
|
253
|
+
out[name] = scheme ? `${scheme} {{secrets.${secret}}}` : `{{secrets.${secret}}}`;
|
|
254
|
+
const firstTime = draft.secret(secret, toolName, `${source} header "${name}"`);
|
|
255
|
+
// ONCE PER SECRET, not once per tool. One token shared by twelve
|
|
256
|
+
// operations produced twelve identical warnings, and a list nobody can
|
|
257
|
+
// skim is a list nobody reads. `secrets[].usedBy` already names every tool
|
|
258
|
+
// that references it, so the detail is not lost.
|
|
259
|
+
if (firstTime) {
|
|
260
|
+
draft.warnings.push({
|
|
261
|
+
kind: 'credential-stripped',
|
|
262
|
+
message: `Header "${name}" held a credential. It is now {{secrets.${secret}}} and the value was ` +
|
|
263
|
+
`NOT kept. Create it with ws.secrets.create({ name: '${secret}', value: '…' }).`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
/** A description the model can act on, or a warning that there is none. */
|
|
270
|
+
function describe(candidates, toolName, method, path, draft) {
|
|
271
|
+
const found = candidates.map((c) => c?.trim()).find((c) => c && c.length >= 12);
|
|
272
|
+
if (found)
|
|
273
|
+
return found;
|
|
274
|
+
// The fallback is `METHOD /path`, and the path must be the RESOLVED one. Using
|
|
275
|
+
// the raw source url put `DELETE {{baseUrl}}/admin/purge` in a description,
|
|
276
|
+
// which shows a model a placeholder it cannot act on and reads like a bug.
|
|
277
|
+
// Collapse doubled slashes left behind by a stripped placeholder, but NOT the
|
|
278
|
+
// `//` in `https://`: a Postman collection that writes absolute urls rather
|
|
279
|
+
// than {{baseUrl}} produced the description "DELETE https:/api.acme.example/…",
|
|
280
|
+
// and that string is what the model reads to choose the tool.
|
|
281
|
+
const stripped = path.replace(/\{\{[^}]*\}\}/g, '');
|
|
282
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(stripped)?.[0] ?? '';
|
|
283
|
+
const clean = `${scheme}${stripped.slice(scheme.length).replace(/\/{2,}/g, '/')}` || path;
|
|
284
|
+
draft.warnings.push({
|
|
285
|
+
kind: 'description-missing',
|
|
286
|
+
tool: toolName,
|
|
287
|
+
message: `"${toolName}" has no usable description. This is the field the model reads to decide ` +
|
|
288
|
+
`whether to call it, so write one line saying when someone would use it. ` +
|
|
289
|
+
`A bad description does not error, it makes the agent pick the wrong tool.`,
|
|
290
|
+
});
|
|
291
|
+
return `${method} ${clean}`;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Draft tools from a Postman collection export (v2.0 or v2.1).
|
|
295
|
+
*
|
|
296
|
+
* WHY POSTMAN AND NOT ONLY OPENAPI. OpenAPI is tidier; a collection is truer. It
|
|
297
|
+
* holds the requests somebody has actually run, with the header names that
|
|
298
|
+
* really work and real values in the body, which is what a tool has to
|
|
299
|
+
* reproduce. Teams also already have one, and the best format to ask for is the
|
|
300
|
+
* one nobody has to make.
|
|
301
|
+
*
|
|
302
|
+
* WHAT IT COSTS: a collection has no parameter TYPES, so every parameter comes
|
|
303
|
+
* back a string. That is usually right and always safe; `fromOpenApi` gives real
|
|
304
|
+
* types where a document exists.
|
|
305
|
+
*
|
|
306
|
+
* VARIABLES. Postman writes `{{baseUrl}}`, which is the same shape as our own
|
|
307
|
+
* placeholders, so they map across almost directly. A variable the collection
|
|
308
|
+
* DEFINES is substituted, unless it holds something that looks like a
|
|
309
|
+
* credential, in which case it becomes a secret instead of being inlined. An
|
|
310
|
+
* undefined variable becomes a secret too, with a warning, because the
|
|
311
|
+
* alternative is a URL with a placeholder nothing resolves.
|
|
312
|
+
*/
|
|
313
|
+
export function fromPostman(collection, options = {}) {
|
|
314
|
+
const draft = new Draft();
|
|
315
|
+
const vars = new Map();
|
|
316
|
+
for (const v of collection.variable ?? []) {
|
|
317
|
+
if (v.key)
|
|
318
|
+
vars.set(v.key, v.value ?? '');
|
|
319
|
+
}
|
|
320
|
+
/** Substitute defined variables; anything else becomes a secret. */
|
|
321
|
+
const resolveVars = (text, toolName) => text.replace(/\{\{([^}]+)\}\}/g, (_m, rawKey) => {
|
|
322
|
+
const keyName = rawKey.trim();
|
|
323
|
+
const value = vars.get(keyName);
|
|
324
|
+
// A DEFINED VARIABLE HOLDING A CREDENTIAL IS STILL A CREDENTIAL. Inlining
|
|
325
|
+
// it because the collection happened to define it is how a live token
|
|
326
|
+
// ends up in an agent version, which is readable.
|
|
327
|
+
if (value !== undefined && value !== '' && !CREDENTIAL_VALUE_RE.test(value)) {
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
const secret = normalizeSecretName(keyName);
|
|
331
|
+
draft.secret(secret, toolName, `collection variable "${keyName}"`);
|
|
332
|
+
if (value === undefined) {
|
|
333
|
+
draft.warnings.push({
|
|
334
|
+
kind: 'undefined-variable',
|
|
335
|
+
tool: toolName,
|
|
336
|
+
message: `{{${keyName}}} is not defined in the collection, so it became {{secrets.${secret}}}.`,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return `{{secrets.${secret}}}`;
|
|
340
|
+
});
|
|
341
|
+
const walk = (items, trail) => {
|
|
342
|
+
for (const item of items) {
|
|
343
|
+
if (item.item) {
|
|
344
|
+
walk(item.item, [...trail, item.name ?? '']);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const request = item.request;
|
|
348
|
+
if (!request || typeof request === 'string')
|
|
349
|
+
continue;
|
|
350
|
+
const rawUrl = typeof request.url === 'string'
|
|
351
|
+
? request.url
|
|
352
|
+
: (request.url?.raw ??
|
|
353
|
+
[
|
|
354
|
+
(request.url?.host ?? []).join('.'),
|
|
355
|
+
(request.url?.path ?? []).join('/'),
|
|
356
|
+
]
|
|
357
|
+
.filter(Boolean)
|
|
358
|
+
.join('/'));
|
|
359
|
+
if (!rawUrl)
|
|
360
|
+
continue;
|
|
361
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
362
|
+
const candidateName = toolNameFrom(item.name ?? `${method} ${rawUrl}`);
|
|
363
|
+
if (options.include && !options.include({ name: candidateName, method, url: rawUrl })) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const name = draft.uniqueName(candidateName);
|
|
367
|
+
// Postman path variables are `:id`; ours are `{{params.id}}`.
|
|
368
|
+
const params = {};
|
|
369
|
+
let url = resolveVars(rawUrl, name).replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, p) => `{{params.${draft.param(params, p, { type: 'string', required: true }, name)}}}`);
|
|
370
|
+
// `?a=1&b=2` in the raw URL is kept as written; the structured `query`
|
|
371
|
+
// array is what a caller edits in Postman, so it wins where present.
|
|
372
|
+
const query = typeof request.url === 'string' ? [] : (request.url?.query ?? []);
|
|
373
|
+
const enabled = query.filter((q) => q.key && !q.disabled);
|
|
374
|
+
if (enabled.length > 0) {
|
|
375
|
+
url = url.split('?')[0];
|
|
376
|
+
const pairs = enabled.map((q) => {
|
|
377
|
+
const key = q.key;
|
|
378
|
+
const param = draft.param(params, key, { type: 'string', ...(q.description ? { description: q.description } : {}) }, name);
|
|
379
|
+
return `${encodeURIComponent(key)}={{paramsEncoded.${param}}}`;
|
|
380
|
+
});
|
|
381
|
+
url = `${url}?${pairs.join('&')}`;
|
|
382
|
+
}
|
|
383
|
+
const headerPairs = {};
|
|
384
|
+
for (const h of request.header ?? []) {
|
|
385
|
+
if (h.key && !h.disabled)
|
|
386
|
+
headerPairs[h.key] = resolveVars(h.value ?? '', name);
|
|
387
|
+
}
|
|
388
|
+
// Collection-level auth applies to a request that declares none.
|
|
389
|
+
const auth = request.auth ?? collection.auth;
|
|
390
|
+
if (auth?.type && !Object.keys(headerPairs).some((k) => CREDENTIAL_HEADERS.has(k.toLowerCase()))) {
|
|
391
|
+
const secret = secretNameFor('authorization', options);
|
|
392
|
+
headerPairs.Authorization =
|
|
393
|
+
auth.type === 'basic' ? `Basic {{secrets.${secret}}}` : `Bearer {{secrets.${secret}}}`;
|
|
394
|
+
draft.secret(secret, name, `collection auth (${auth.type})`);
|
|
395
|
+
}
|
|
396
|
+
const headers = safeHeaders(headerPairs, name, draft, options, 'Postman');
|
|
397
|
+
let body;
|
|
398
|
+
const mode = request.body?.mode;
|
|
399
|
+
if (mode === 'raw' && request.body?.raw) {
|
|
400
|
+
body = resolveVars(request.body.raw, name);
|
|
401
|
+
}
|
|
402
|
+
else if (mode && mode !== 'raw' && mode !== 'none') {
|
|
403
|
+
draft.warnings.push({
|
|
404
|
+
kind: 'unsupported-body',
|
|
405
|
+
tool: name,
|
|
406
|
+
message: `Body mode "${mode}" is not supported; the tool was created without a body.`,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
draft.tools.push({
|
|
410
|
+
name,
|
|
411
|
+
description: describe([item.description, request.description, trail.filter(Boolean).join(' / ')], name, method, rawUrl, draft),
|
|
412
|
+
method: (['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(method)
|
|
413
|
+
? method
|
|
414
|
+
: 'GET'),
|
|
415
|
+
url,
|
|
416
|
+
...(Object.keys(headers).length > 0 && { headers }),
|
|
417
|
+
...(Object.keys(params).length > 0 && { params }),
|
|
418
|
+
// DECLARED, because the placeholder alone does not reach the runtime.
|
|
419
|
+
...(draft.secretsFor(name).length > 0 && { secrets: draft.secretsFor(name) }),
|
|
420
|
+
// GET carries no body: the API refuses one at authoring time.
|
|
421
|
+
...(body !== undefined && method !== 'GET' && { body }),
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
walk(collection.item ?? [], []);
|
|
426
|
+
return draft.finish(options);
|
|
427
|
+
}
|
|
428
|
+
const OPENAPI_METHODS = ['get', 'post', 'put', 'delete', 'patch'];
|
|
429
|
+
function openApiType(t) {
|
|
430
|
+
if (t === 'integer' || t === 'number')
|
|
431
|
+
return 'number';
|
|
432
|
+
if (t === 'boolean')
|
|
433
|
+
return 'boolean';
|
|
434
|
+
return 'string';
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Draft tools from an OpenAPI 3 or Swagger 2 document.
|
|
438
|
+
*
|
|
439
|
+
* WHAT THIS GIVES THAT POSTMAN DOES NOT: real parameter types and required
|
|
440
|
+
* flags, which is exactly the shape `HttpToolParam` wants, and an enum where the
|
|
441
|
+
* document declares one. FastAPI publishes this at `/openapi.json` without
|
|
442
|
+
* anybody writing it, so for a FastAPI shop this is usually the richer source.
|
|
443
|
+
*
|
|
444
|
+
* WHAT IT STILL DOES NOT GIVE: a description a model can choose on. `summary`
|
|
445
|
+
* is written for a developer reading a reference. Read the warnings.
|
|
446
|
+
*/
|
|
447
|
+
export function fromOpenApi(doc, options = {}) {
|
|
448
|
+
const draft = new Draft();
|
|
449
|
+
// OpenAPI 3 `servers`, or Swagger 2's three separate fields.
|
|
450
|
+
const base = doc.servers?.[0]?.url ??
|
|
451
|
+
(doc.host ? `${doc.schemes?.[0] ?? 'https'}://${doc.host}${doc.basePath ?? ''}` : '');
|
|
452
|
+
if (!base) {
|
|
453
|
+
draft.warnings.push({
|
|
454
|
+
kind: 'undefined-variable',
|
|
455
|
+
message: 'The document names no server, so every url is a path with no host. Set one, or edit ' +
|
|
456
|
+
'the urls before creating the agent: the API refuses a url that is not absolute.',
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
const schemes = doc.components?.securitySchemes ?? doc.securityDefinitions ?? {};
|
|
460
|
+
const firstScheme = Object.values(schemes)[0];
|
|
461
|
+
for (const [path, operations] of Object.entries(doc.paths ?? {})) {
|
|
462
|
+
for (const method of OPENAPI_METHODS) {
|
|
463
|
+
const op = operations[method];
|
|
464
|
+
if (!op)
|
|
465
|
+
continue;
|
|
466
|
+
const candidateName = toolNameFrom(op.operationId ?? `${method}_${path}`, 'op');
|
|
467
|
+
const fullUrl = `${base}${path}`;
|
|
468
|
+
if (options.include && !options.include({ name: candidateName, method: method.toUpperCase(), url: fullUrl })) {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
const name = draft.uniqueName(candidateName);
|
|
472
|
+
const params = {};
|
|
473
|
+
const headerPairs = {};
|
|
474
|
+
// A path template's `{order-id}` is a wire name like any other, so it
|
|
475
|
+
// gets the same normalisation as a query key. Declared or not: a brace
|
|
476
|
+
// in the path has to resolve, and an operation that never declared the
|
|
477
|
+
// parameter would otherwise leave `{{params.order-id}}` in the url.
|
|
478
|
+
let url = `${base}${path}`.replace(/\{([^}]+)\}/g, (_m, p) => `{{params.${draft.param(params, p, { type: 'string', required: true }, name)}}}`);
|
|
479
|
+
const queryPairs = [];
|
|
480
|
+
for (const p of op.parameters ?? []) {
|
|
481
|
+
if (!p.name)
|
|
482
|
+
continue;
|
|
483
|
+
const def = {
|
|
484
|
+
type: openApiType(p.schema?.type),
|
|
485
|
+
...(p.description ? { description: p.description } : {}),
|
|
486
|
+
...(p.required ? { required: true } : {}),
|
|
487
|
+
...(p.schema?.enum ? { choices: p.schema.enum } : {}),
|
|
488
|
+
};
|
|
489
|
+
// A CREDENTIAL HEADER IS NEVER A PARAM. The model would have to be
|
|
490
|
+
// handed the token to fill it in, and the whole point of the secret
|
|
491
|
+
// placeholder is that it never sees one.
|
|
492
|
+
if (p.in === 'header' && CREDENTIAL_HEADERS.has(p.name.toLowerCase())) {
|
|
493
|
+
const secret = secretNameFor(p.name, options);
|
|
494
|
+
headerPairs[p.name] = `{{secrets.${secret}}}`;
|
|
495
|
+
draft.secret(secret, name, `securityScheme (header parameter "${p.name}")`);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
// A cookie parameter cannot be expressed: it would have to be merged
|
|
499
|
+
// into a single Cookie header with every other one. Say so rather
|
|
500
|
+
// than declaring a param that goes nowhere.
|
|
501
|
+
if (p.in === 'cookie') {
|
|
502
|
+
draft.warnings.push({
|
|
503
|
+
kind: 'unsupported-body',
|
|
504
|
+
tool: name,
|
|
505
|
+
message: `Cookie parameter "${p.name}" is not supported; the tool was created without it.`,
|
|
506
|
+
});
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const param = draft.param(params, p.name, def, name);
|
|
510
|
+
if (p.in === 'query') {
|
|
511
|
+
queryPairs.push(`${encodeURIComponent(p.name)}={{paramsEncoded.${param}}}`);
|
|
512
|
+
}
|
|
513
|
+
else if (p.in === 'header') {
|
|
514
|
+
// DECLARED AND NEVER SENT is the failure this closes. Every
|
|
515
|
+
// parameter used to land in `params` whatever its `in` was, but
|
|
516
|
+
// only query and path were wired to anything, so a required
|
|
517
|
+
// `X-Tenant-Id` made the model supply a value that was dropped on
|
|
518
|
+
// the floor and the request went out without the header.
|
|
519
|
+
headerPairs[p.name] = `{{params.${param}}}`;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (queryPairs.length > 0)
|
|
523
|
+
url = `${url}?${queryPairs.join('&')}`;
|
|
524
|
+
if (firstScheme) {
|
|
525
|
+
const secret = secretNameFor(firstScheme.in === 'header' && firstScheme.name ? firstScheme.name : 'authorization', options);
|
|
526
|
+
const headerName = firstScheme.type === 'apiKey' && firstScheme.in === 'header' && firstScheme.name
|
|
527
|
+
? firstScheme.name
|
|
528
|
+
: 'Authorization';
|
|
529
|
+
headerPairs[headerName] =
|
|
530
|
+
headerName.toLowerCase() === 'authorization'
|
|
531
|
+
? `${firstScheme.scheme === 'basic' ? 'Basic' : 'Bearer'} {{secrets.${secret}}}`
|
|
532
|
+
: `{{secrets.${secret}}}`;
|
|
533
|
+
draft.secret(secret, name, `securityScheme (${firstScheme.type ?? 'unknown'})`);
|
|
534
|
+
}
|
|
535
|
+
const headers = safeHeaders(headerPairs, name, draft, options, 'OpenAPI');
|
|
536
|
+
const upper = method.toUpperCase();
|
|
537
|
+
let body;
|
|
538
|
+
if (op.requestBody && upper !== 'GET') {
|
|
539
|
+
const types = Object.keys(op.requestBody.content ?? {});
|
|
540
|
+
if (types.some((t) => t.includes('json'))) {
|
|
541
|
+
// A LITERAL EXAMPLE IS NOT INVENTED HERE. The schema says what the
|
|
542
|
+
// shape is; guessing values would produce a tool that posts
|
|
543
|
+
// plausible nonsense. The agent fills this in, so it is left for
|
|
544
|
+
// the developer to shape.
|
|
545
|
+
body = '{{paramsJson.body}}';
|
|
546
|
+
params.body = { type: 'string', description: 'JSON body for this request.', required: true };
|
|
547
|
+
}
|
|
548
|
+
else if (types.length > 0) {
|
|
549
|
+
draft.warnings.push({
|
|
550
|
+
kind: 'unsupported-body',
|
|
551
|
+
tool: name,
|
|
552
|
+
message: `Request body is ${types.join(', ')}, which is not supported; the tool was created without one.`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
draft.tools.push({
|
|
557
|
+
name,
|
|
558
|
+
description: describe([op.description, op.summary], name, upper, path, draft),
|
|
559
|
+
method: upper,
|
|
560
|
+
url,
|
|
561
|
+
...(Object.keys(headers).length > 0 && { headers }),
|
|
562
|
+
...(Object.keys(params).length > 0 && { params }),
|
|
563
|
+
...(draft.secretsFor(name).length > 0 && { secrets: draft.secretsFor(name) }),
|
|
564
|
+
...(body !== undefined && { body }),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return draft.finish(options);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Fetch an OpenAPI document and draft tools from it.
|
|
572
|
+
*
|
|
573
|
+
* THE FETCH GOES TO YOUR SERVER, NOT OURS. Pass `headers` if the document itself
|
|
574
|
+
* is behind auth. Nothing about the document is sent to AgentNava: the drafts
|
|
575
|
+
* are built in this process and you decide what to do with them.
|
|
576
|
+
*/
|
|
577
|
+
export async function fromOpenApiUrl(url, options = {}) {
|
|
578
|
+
const f = options.fetch ?? fetch;
|
|
579
|
+
const res = await f(url, { headers: options.headers ?? {} });
|
|
580
|
+
if (!res.ok) {
|
|
581
|
+
throw new Error(`could not read the OpenAPI document at ${url}: ${res.status}`);
|
|
582
|
+
}
|
|
583
|
+
return fromOpenApi((await res.json()), options);
|
|
584
|
+
}
|
|
585
|
+
// ------------------------------------------------------------------- curl
|
|
586
|
+
/**
|
|
587
|
+
* Draft one tool from a curl command.
|
|
588
|
+
*
|
|
589
|
+
* THE LOWEST BAR ON PURPOSE. A team with no collection and no document still has
|
|
590
|
+
* a curl command that works, usually pasted in a chat thread, and that is enough
|
|
591
|
+
* to start. Handles the flags people actually use: `-X`, `-H`, `-d`/`--data`,
|
|
592
|
+
* `--data-raw`, `--url`, and a bare URL.
|
|
593
|
+
*
|
|
594
|
+
* Give it a `name` and a `description`: a curl command carries neither, and the
|
|
595
|
+
* description is the field that decides whether the agent picks this tool.
|
|
596
|
+
*/
|
|
597
|
+
export function fromCurl(command, options = {}) {
|
|
598
|
+
const draft = new Draft();
|
|
599
|
+
// Join line continuations, then split on whitespace outside quotes.
|
|
600
|
+
const flat = command.replace(/\\\r?\n/g, ' ').trim();
|
|
601
|
+
const tokens = flat.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
602
|
+
const unquote = (s) => (s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))
|
|
603
|
+
? s.slice(1, -1)
|
|
604
|
+
: s;
|
|
605
|
+
let method = '';
|
|
606
|
+
let url = '';
|
|
607
|
+
let body;
|
|
608
|
+
const headerPairs = {};
|
|
609
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
610
|
+
const t = tokens[i];
|
|
611
|
+
if (t === 'curl')
|
|
612
|
+
continue;
|
|
613
|
+
if (t === '-X' || t === '--request') {
|
|
614
|
+
method = unquote(tokens[++i] ?? 'GET').toUpperCase();
|
|
615
|
+
}
|
|
616
|
+
else if (t === '-H' || t === '--header') {
|
|
617
|
+
const raw = unquote(tokens[++i] ?? '');
|
|
618
|
+
const at = raw.indexOf(':');
|
|
619
|
+
if (at > 0)
|
|
620
|
+
headerPairs[raw.slice(0, at).trim()] = raw.slice(at + 1).trim();
|
|
621
|
+
}
|
|
622
|
+
else if (t === '-d' || t === '--data' || t === '--data-raw' || t === '--data-binary') {
|
|
623
|
+
body = unquote(tokens[++i] ?? '');
|
|
624
|
+
}
|
|
625
|
+
else if (t === '-u' || t === '--user') {
|
|
626
|
+
// `-u user:pass` IS a credential, and it used to vanish without a trace:
|
|
627
|
+
// `-u` matched no branch, and `user:pass` matched neither `^-` nor a
|
|
628
|
+
// url, so both tokens were skipped in silence. Routed through the
|
|
629
|
+
// Authorization header so safeHeaders strips it like any other, which
|
|
630
|
+
// also produces the credential-stripped warning.
|
|
631
|
+
headerPairs.Authorization = `Basic ${unquote(tokens[++i] ?? '')}`;
|
|
632
|
+
}
|
|
633
|
+
else if (t === '-b' || t === '--cookie') {
|
|
634
|
+
headerPairs.Cookie = unquote(tokens[++i] ?? '');
|
|
635
|
+
}
|
|
636
|
+
else if (t === '--url') {
|
|
637
|
+
url = unquote(tokens[++i] ?? '');
|
|
638
|
+
}
|
|
639
|
+
else if (!t.startsWith('-') && /^https?:\/\//.test(unquote(t))) {
|
|
640
|
+
url = unquote(t);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (!url)
|
|
644
|
+
throw new Error('no URL found in that curl command');
|
|
645
|
+
// curl's own default: a body implies POST unless -X said otherwise.
|
|
646
|
+
if (!method)
|
|
647
|
+
method = body !== undefined ? 'POST' : 'GET';
|
|
648
|
+
const name = draft.uniqueName(toolNameFrom(options.name ?? `${method} ${url}`));
|
|
649
|
+
const headers = safeHeaders(headerPairs, name, draft, options, 'curl');
|
|
650
|
+
draft.tools.push({
|
|
651
|
+
name,
|
|
652
|
+
description: describe([options.description], name, method, url, draft),
|
|
653
|
+
method: (['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(method)
|
|
654
|
+
? method
|
|
655
|
+
: 'GET'),
|
|
656
|
+
url,
|
|
657
|
+
...(Object.keys(headers).length > 0 && { headers }),
|
|
658
|
+
...(draft.secretsFor(name).length > 0 && { secrets: draft.secretsFor(name) }),
|
|
659
|
+
...(body !== undefined && method !== 'GET' && { body }),
|
|
660
|
+
});
|
|
661
|
+
return draft.finish(options);
|
|
662
|
+
}
|
|
663
|
+
export function importTools(source, options = {}) {
|
|
664
|
+
if ('openapi' in source)
|
|
665
|
+
return fromOpenApi(source.openapi, options);
|
|
666
|
+
if ('postman' in source)
|
|
667
|
+
return fromPostman(source.postman, options);
|
|
668
|
+
if ('curl' in source) {
|
|
669
|
+
return fromCurl(source.curl, {
|
|
670
|
+
...options,
|
|
671
|
+
...(source.name !== undefined && { name: source.name }),
|
|
672
|
+
...(source.description !== undefined && { description: source.description }),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if ('openapiUrl' in source) {
|
|
676
|
+
return fromOpenApiUrl(source.openapiUrl, {
|
|
677
|
+
...options,
|
|
678
|
+
...(source.headers !== undefined && { headers: source.headers }),
|
|
679
|
+
...(source.fetch !== undefined && { fetch: source.fetch }),
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
// Named, not "invalid source": a caller who passed the wrong key wants to
|
|
683
|
+
// know which keys exist.
|
|
684
|
+
throw new Error('importTools needs one of: { openapi }, { openapiUrl }, { postman }, { curl }');
|
|
685
|
+
}
|
|
686
|
+
//# sourceMappingURL=import-tools.js.map
|