@argentic-ai/client 0.0.0-stage → 0.1.14
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 +9 -0
- package/README.md +107 -2
- package/dist/ai-sdk.d.ts +9 -0
- package/dist/ai-sdk.js +32 -0
- package/dist/argentic.d.ts +376 -0
- package/dist/argentic.js +564 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +197 -0
- package/dist/mastra.d.ts +14 -0
- package/dist/mastra.js +29 -0
- package/dist/tools.d.ts +75 -0
- package/dist/tools.js +570 -0
- package/package.json +40 -4
package/dist/argentic.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// Argentic core: a small client and a durable tool loop. No dependencies, Node 22+.
|
|
2
|
+
// Argentic runs the agent loop; your code carries out the tool calls it asks for.
|
|
3
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
export const defaultBaseUrl = 'https://api.getargentic.com';
|
|
7
|
+
/** A wake can run a long time, so a try waits five minutes, as Python's client does. */
|
|
8
|
+
export const defaultTimeoutMs = 300_000;
|
|
9
|
+
/** The free default model. */
|
|
10
|
+
export const freeModel = {
|
|
11
|
+
provider: 'nvidia',
|
|
12
|
+
id: 'nvidia/nemotron-3.5-lightning-30b-a3b',
|
|
13
|
+
prices: { in: 0, out: 0 },
|
|
14
|
+
};
|
|
15
|
+
const propKeys = ['type', 'description', 'enum'];
|
|
16
|
+
/** True when any top-level property is an object, or an array of objects (or of anything that is not a plain scalar). */
|
|
17
|
+
function isNested(schema) {
|
|
18
|
+
const flat = ['string', 'number', 'integer', 'boolean'];
|
|
19
|
+
return Object.values(schema?.properties ?? {}).some((p) => {
|
|
20
|
+
if (p?.type === 'object' || p?.properties)
|
|
21
|
+
return true;
|
|
22
|
+
return p?.type === 'array' && p.items && !flat.includes(p.items.type);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Trim a JSON schema to the subset Argentic accepts: an object of string, number,
|
|
27
|
+
* integer, boolean or array-of-those properties, with description, enum and required.
|
|
28
|
+
* Framework extras ($schema, additionalProperties, title, defaults) are dropped.
|
|
29
|
+
*/
|
|
30
|
+
export function toArgenticSchema(schema) {
|
|
31
|
+
// A nested argument (an object, or an array of objects) cannot be trimmed to the flat subset without losing what is inside it. The API reads such a schema as a
|
|
32
|
+
// JSON Schema for the arguments, so it goes through as it is, less the framework's `$schema` marker.
|
|
33
|
+
if (isNested(schema)) {
|
|
34
|
+
const { $schema: _, ...rest } = schema;
|
|
35
|
+
return rest;
|
|
36
|
+
}
|
|
37
|
+
const properties = {};
|
|
38
|
+
for (const [name, prop] of Object.entries(schema?.properties ?? {})) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const k of propKeys)
|
|
41
|
+
if (prop[k] !== undefined)
|
|
42
|
+
out[k] = prop[k];
|
|
43
|
+
if (prop.type === 'array' && prop.items?.type)
|
|
44
|
+
out.items = { type: prop.items.type };
|
|
45
|
+
properties[name] = out;
|
|
46
|
+
}
|
|
47
|
+
const result = { type: 'object', properties };
|
|
48
|
+
if (Array.isArray(schema?.required) && schema.required.length)
|
|
49
|
+
result.required = schema.required;
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
const ONCE_PER_PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]{0,39})\}/g;
|
|
53
|
+
/**
|
|
54
|
+
* Check a `once_per` template the way the API does, so a mistake fails here with a clear message instead of as a refused wake.
|
|
55
|
+
* `parameters` is the tool's JSON schema (before or after `toArgenticSchema`). Throws; returns nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function checkOncePer(tool, template, parameters) {
|
|
58
|
+
const who = `tool "${tool}": `;
|
|
59
|
+
if (typeof template !== 'string')
|
|
60
|
+
throw new Error(`${who}once_per must be a string, such as "refund:{order_id}"`);
|
|
61
|
+
if (template.length < 3 || template.length > 120)
|
|
62
|
+
throw new Error(`${who}once_per must be 3 to 120 characters`);
|
|
63
|
+
if (!/^[A-Za-z0-9_:.\-{}]+$/.test(template))
|
|
64
|
+
throw new Error(`${who}once_per: letters, digits and _ : . - plus {argument} placeholders`);
|
|
65
|
+
const names = [...template.matchAll(ONCE_PER_PLACEHOLDER)].map((m) => m[1]);
|
|
66
|
+
if (names.length === 0)
|
|
67
|
+
throw new Error(`${who}once_per needs at least one {argument}, such as "refund:{order_id}"`);
|
|
68
|
+
if (/[{}]/.test(template.replace(ONCE_PER_PLACEHOLDER, '')))
|
|
69
|
+
throw new Error(`${who}once_per has an unmatched brace`);
|
|
70
|
+
const props = (parameters?.properties ?? {});
|
|
71
|
+
const required = parameters?.required;
|
|
72
|
+
for (const n of names) {
|
|
73
|
+
const p = Object.hasOwn(props, n) ? props[n] : undefined;
|
|
74
|
+
if (!p || p.type === 'array')
|
|
75
|
+
throw new Error(`${who}once_per {${n}} must be a string, number, integer or boolean property of this tool`);
|
|
76
|
+
if (!Array.isArray(required) || !required.includes(n))
|
|
77
|
+
throw new Error(`${who}once_per {${n}} must be a required property, so every call has a key`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Set `once_per` on tools by name (for the AI SDK and Mastra adapters, whose tools have nowhere to carry it). Throws on a name that is not a tool or a template the API would refuse. */
|
|
81
|
+
export function withOncePer(tools, oncePer) {
|
|
82
|
+
if (!oncePer)
|
|
83
|
+
return tools;
|
|
84
|
+
const names = new Set(tools.map((t) => t.name));
|
|
85
|
+
for (const k of Object.keys(oncePer))
|
|
86
|
+
if (!names.has(k))
|
|
87
|
+
throw new Error(`oncePer names "${k}", which is not one of the tools`);
|
|
88
|
+
return tools.map((t) => {
|
|
89
|
+
if (!Object.hasOwn(oncePer, t.name))
|
|
90
|
+
return t;
|
|
91
|
+
checkOncePer(t.name, oncePer[t.name], toArgenticSchema(t.parameters));
|
|
92
|
+
return { ...t, once_per: oncePer[t.name] };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export class ArgenticError extends Error {
|
|
96
|
+
status;
|
|
97
|
+
body;
|
|
98
|
+
constructor(message, status, body) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.status = status;
|
|
101
|
+
this.body = body;
|
|
102
|
+
this.name = 'ArgenticError';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** True for a 400 whose detail names `results`: a server that does not know the field yet (strict body validation). */
|
|
106
|
+
function refusesResults(e) {
|
|
107
|
+
if (!(e instanceof ArgenticError) || e.status !== 400)
|
|
108
|
+
return false;
|
|
109
|
+
const detail = e.body?.detail;
|
|
110
|
+
if (detail === undefined)
|
|
111
|
+
return false;
|
|
112
|
+
return (typeof detail === 'string' ? detail : JSON.stringify(detail)).includes('results');
|
|
113
|
+
}
|
|
114
|
+
/** A next step for an error a first run tends to hit, or undefined. Examples print it instead of a stack trace. */
|
|
115
|
+
export function hintFor(e) {
|
|
116
|
+
if (!(e instanceof ArgenticError))
|
|
117
|
+
return undefined;
|
|
118
|
+
const code = e.body?.error;
|
|
119
|
+
if (e.status === 401)
|
|
120
|
+
return 'Check ARGENTIC_API_KEY. A key only works on the API it was made for, so if you set ARGENTIC_BASE_URL, make the key in that dashboard.';
|
|
121
|
+
if (code === 'model_key_rejected')
|
|
122
|
+
return 'Your model provider refused MODEL_KEY. Check it is a key for the provider in the error (a free NVIDIA key starts with nvapi-).';
|
|
123
|
+
if (code === 'bad_model')
|
|
124
|
+
return 'Your provider does not know this model id. Set ARGENTIC_MODEL_ID to one it serves, or unset it for the free default.';
|
|
125
|
+
if (e.status === 402)
|
|
126
|
+
return 'This account hit its usage limit. Argentic is free in beta, so there is nothing to buy: email hi@getargentic.com and we will raise it.';
|
|
127
|
+
if (e.status === 429)
|
|
128
|
+
return 'Too many requests a minute for this key. Wait for the time in the error and run the same command again.';
|
|
129
|
+
if (e.status === undefined || e.status >= 500)
|
|
130
|
+
return 'The API did not answer. Run the same command again in a minute: it picks up where it stopped.';
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
134
|
+
export class Argentic {
|
|
135
|
+
apiKey;
|
|
136
|
+
modelKey;
|
|
137
|
+
baseUrl;
|
|
138
|
+
timeoutMs;
|
|
139
|
+
doFetch;
|
|
140
|
+
backoffMs;
|
|
141
|
+
maxQueuedWaits;
|
|
142
|
+
sleep;
|
|
143
|
+
/** Set once a server has refused `results` as an unknown field; later wakes of this client leave it out. */
|
|
144
|
+
resultsRefused = false;
|
|
145
|
+
constructor(opts = {}) {
|
|
146
|
+
this.apiKey = opts.apiKey ?? process.env.ARGENTIC_API_KEY ?? '';
|
|
147
|
+
this.modelKey = opts.modelKey || process.env.MODEL_KEY || undefined;
|
|
148
|
+
this.timeoutMs = opts.timeoutMs ?? defaultTimeoutMs;
|
|
149
|
+
this.baseUrl = (opts.baseUrl ?? process.env.ARGENTIC_BASE_URL ?? defaultBaseUrl).replace(/\/+$/, '');
|
|
150
|
+
this.doFetch = opts.fetch ?? fetch;
|
|
151
|
+
this.backoffMs = opts.backoffMs ?? [1000, 2000, 4000, 8000];
|
|
152
|
+
this.maxQueuedWaits = opts.maxQueuedWaits ?? 60;
|
|
153
|
+
this.sleep = opts.sleep ?? wait;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Wake an agent with a message. Safe to call again with the same key:
|
|
157
|
+
* a queued (409) answer waits and resends, a 5xx or network error retries
|
|
158
|
+
* with backoff, and a reused key with a different body throws. If the server answers 400 and names `results`, it does
|
|
159
|
+
* not know that field yet: the same wake goes out once more under the same key without `results`, and this client leaves
|
|
160
|
+
* `results` out from then on.
|
|
161
|
+
*/
|
|
162
|
+
async wake(agent, body, idempotencyKey) {
|
|
163
|
+
const headers = {
|
|
164
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
165
|
+
'Content-Type': 'application/json',
|
|
166
|
+
'Idempotency-Key': idempotencyKey,
|
|
167
|
+
};
|
|
168
|
+
if (this.modelKey)
|
|
169
|
+
headers['X-Model-Key'] = this.modelKey;
|
|
170
|
+
const path = `/v1/agents/${encodeURIComponent(agent)}/wake`;
|
|
171
|
+
const { results, ...plain } = body;
|
|
172
|
+
let res;
|
|
173
|
+
if (results === undefined || this.resultsRefused) {
|
|
174
|
+
res = await this.send(path, JSON.stringify(plain), headers);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
try {
|
|
178
|
+
res = await this.send(path, JSON.stringify(body), headers);
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
if (!refusesResults(e))
|
|
182
|
+
throw e;
|
|
183
|
+
this.resultsRefused = true;
|
|
184
|
+
res = await this.send(path, JSON.stringify(plain), headers);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const json = (await res.json());
|
|
188
|
+
return { ...json, idempotentReplay: res.headers.get('Idempotent-Replay') === 'true' };
|
|
189
|
+
}
|
|
190
|
+
/** Re-render a wake's prompts from the journal and compare them to what the model was shown. Spends nothing. */
|
|
191
|
+
async replay(agent, wakeId) {
|
|
192
|
+
const headers = { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' };
|
|
193
|
+
const res = await this.send(`/v1/agents/${encodeURIComponent(agent)}/replay`, JSON.stringify({ wake: wakeId }), headers);
|
|
194
|
+
const json = (await res.json());
|
|
195
|
+
const turns = json.turns ?? [];
|
|
196
|
+
const count = (pred) => turns.filter(pred).length;
|
|
197
|
+
return {
|
|
198
|
+
...json,
|
|
199
|
+
turns,
|
|
200
|
+
matched: json.matched ?? count((t) => t.match === true),
|
|
201
|
+
mismatched: json.mismatched ?? count((t) => t.match === false),
|
|
202
|
+
unverifiable: json.unverifiable ?? count((t) => t.match === null),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** What is due now: plans past their wake_at, schedules and timers whose time has come, and agents waiting on an answered approval or a hook. Read only; costs nothing. */
|
|
206
|
+
async due(opts = {}) {
|
|
207
|
+
const q = opts.limit === undefined ? '' : `?limit=${encodeURIComponent(String(opts.limit))}`;
|
|
208
|
+
const res = await this.send(`/v1/due${q}`, undefined, this.plainHeaders(), 'GET');
|
|
209
|
+
return (await res.json()).due ?? [];
|
|
210
|
+
}
|
|
211
|
+
/** Create or replace a recurring schedule (`cron`, five UTC fields) or a one-shot timer (`at`, ISO 8601 with an offset) on an agent. Needs the hosted API to have schedules switched on. */
|
|
212
|
+
async putSchedule(agent, name, body, opts = {}) {
|
|
213
|
+
const res = await this.send(this.schedulePath(agent, name, opts.memory), JSON.stringify(body), { ...this.plainHeaders(), 'Content-Type': 'application/json' }, 'PUT');
|
|
214
|
+
return (await res.json()).schedule;
|
|
215
|
+
}
|
|
216
|
+
/** The schedules and timers of one agent. */
|
|
217
|
+
async schedules(agent, opts = {}) {
|
|
218
|
+
const res = await this.send(this.schedulePath(agent, undefined, opts.memory), undefined, this.plainHeaders(), 'GET');
|
|
219
|
+
return (await res.json()).schedules ?? [];
|
|
220
|
+
}
|
|
221
|
+
/** Delete a schedule or timer. Throws a 404 ArgenticError if it does not exist. */
|
|
222
|
+
async deleteSchedule(agent, name, opts = {}) {
|
|
223
|
+
await this.send(this.schedulePath(agent, name, opts.memory), undefined, this.plainHeaders(), 'DELETE');
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Declare an agent's tools once (at most 32). A wake that sends no `tools` uses them; a wake that does send `tools` uses
|
|
227
|
+
* those instead. Replaces what was stored; an empty list clears it. Returns the stored tools.
|
|
228
|
+
*/
|
|
229
|
+
async putTools(agent, tools) {
|
|
230
|
+
const res = await this.send(`/v1/agents/${encodeURIComponent(agent)}/tools`, JSON.stringify({ tools }), { ...this.plainHeaders(), 'Content-Type': 'application/json' }, 'PUT');
|
|
231
|
+
return (await res.json()).tools ?? [];
|
|
232
|
+
}
|
|
233
|
+
/** The tools stored for an agent with putTools(), or an empty list. */
|
|
234
|
+
async tools(agent) {
|
|
235
|
+
const res = await this.send(`/v1/agents/${encodeURIComponent(agent)}/tools`, undefined, this.plainHeaders(), 'GET');
|
|
236
|
+
return (await res.json()).tools ?? [];
|
|
237
|
+
}
|
|
238
|
+
/** Where Argentic posts a signed `agent.due` notice (https, port 443, a public host), or null to stop the notices and keep polling due(). Returns the stored address. */
|
|
239
|
+
async setWakeUrl(url) {
|
|
240
|
+
const res = await this.send('/v1/settings/wake_url', JSON.stringify({ url }), { ...this.plainHeaders(), 'Content-Type': 'application/json' }, 'PUT');
|
|
241
|
+
return (await res.json()).wake_url;
|
|
242
|
+
}
|
|
243
|
+
/** The wake URL and the signing secret (`whsec_...`) to give handleDue as `secret`. */
|
|
244
|
+
async notices() {
|
|
245
|
+
const res = await this.send('/v1/settings/notices', undefined, this.plainHeaders(), 'GET');
|
|
246
|
+
return (await res.json());
|
|
247
|
+
}
|
|
248
|
+
plainHeaders() {
|
|
249
|
+
return { Authorization: `Bearer ${this.apiKey}` };
|
|
250
|
+
}
|
|
251
|
+
schedulePath(agent, name, memory) {
|
|
252
|
+
const base = `/v1/agents/${encodeURIComponent(agent)}/schedules${name === undefined ? '' : `/${encodeURIComponent(name)}`}`;
|
|
253
|
+
return memory === undefined ? base : `${base}?memory=${encodeURIComponent(memory)}`;
|
|
254
|
+
}
|
|
255
|
+
async send(path, body, headers, method = 'POST') {
|
|
256
|
+
let failures = 0;
|
|
257
|
+
let queued = 0;
|
|
258
|
+
for (;;) {
|
|
259
|
+
let res;
|
|
260
|
+
let netError;
|
|
261
|
+
try {
|
|
262
|
+
res = await this.doFetch(this.baseUrl + path, {
|
|
263
|
+
method,
|
|
264
|
+
headers,
|
|
265
|
+
...(body === undefined ? {} : { body }),
|
|
266
|
+
...(this.timeoutMs > 0 ? { signal: AbortSignal.timeout(this.timeoutMs) } : {}),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
netError = e;
|
|
271
|
+
}
|
|
272
|
+
if (res && res.ok)
|
|
273
|
+
return res;
|
|
274
|
+
// 409 on a wake means queued behind another call; on a schedule write it means a limit, which is the caller's to read.
|
|
275
|
+
if (res && res.status === 409 && method === 'POST') {
|
|
276
|
+
const info = (await res.json().catch(() => ({})));
|
|
277
|
+
if (++queued > this.maxQueuedWaits)
|
|
278
|
+
throw new ArgenticError('still queued after waiting', 409, info);
|
|
279
|
+
await this.sleep(Math.min(Math.max(info.retry_after ?? 1, 0), 5) * 1000);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (res && res.status < 500) {
|
|
283
|
+
const info = await res.json().catch(() => undefined);
|
|
284
|
+
const { error: code, detail } = (info ?? {});
|
|
285
|
+
const more = detail === undefined ? '' : `: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`;
|
|
286
|
+
throw new ArgenticError(`${res.status} ${code ?? res.statusText}${more}`, res.status, info);
|
|
287
|
+
}
|
|
288
|
+
// 5xx or network error: same request, same key.
|
|
289
|
+
if (failures >= this.backoffMs.length) {
|
|
290
|
+
const what = res ? `HTTP ${res.status}` : String(netError?.message ?? netError);
|
|
291
|
+
throw new ArgenticError(`gave up after ${failures + 1} tries: ${what}`, res?.status);
|
|
292
|
+
}
|
|
293
|
+
await this.sleep(this.backoffMs[failures++]);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** The notice cannot be trusted or read. Nothing was woken. `code` says why. */
|
|
298
|
+
export class WebhookVerificationError extends Error {
|
|
299
|
+
code;
|
|
300
|
+
constructor(message, code) {
|
|
301
|
+
super(message);
|
|
302
|
+
this.code = code;
|
|
303
|
+
this.name = 'WebhookVerificationError';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/** `v1,<base64 HMAC-SHA256>` over "id.timestamp.body", keyed by the base64-decoded part of the secret after `whsec_`. Standard Webhooks. */
|
|
307
|
+
export function signNotice(secret, id, timestamp, body) {
|
|
308
|
+
return `v1,${createHmac('sha256', secretKey(secret)).update(`${id}.${timestamp}.${body}`).digest('base64')}`;
|
|
309
|
+
}
|
|
310
|
+
function secretKey(secret) {
|
|
311
|
+
const b64 = (secret ?? '').replace(/^whsec_/, '');
|
|
312
|
+
const key = /^[A-Za-z0-9+/]+={0,2}$/.test(b64) && b64.length % 4 === 0 ? Buffer.from(b64, 'base64') : undefined;
|
|
313
|
+
if (!key?.length)
|
|
314
|
+
throw new WebhookVerificationError('the webhook secret is missing or not a whsec_ base64 string (set ARGENTIC_WEBHOOK_SECRET)', 'bad_secret');
|
|
315
|
+
return key;
|
|
316
|
+
}
|
|
317
|
+
async function readDue(req) {
|
|
318
|
+
if (typeof Request !== 'undefined' && req instanceof Request)
|
|
319
|
+
return { body: await req.text(), get: (n) => req.headers.get(n) ?? undefined };
|
|
320
|
+
const { body, headers } = req;
|
|
321
|
+
if (headers instanceof Headers)
|
|
322
|
+
return { body, get: (n) => headers.get(n) ?? undefined };
|
|
323
|
+
const lower = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
324
|
+
return { body, get: (n) => lower.get(n) };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Check the signature and the time, then parse the notice. No network. The signature is checked against the body exactly as it arrived,
|
|
328
|
+
* so pass the raw text, not re-serialized JSON. Use it to queue the notice and wake later; the returned `id` is the key to wake with.
|
|
329
|
+
*/
|
|
330
|
+
export async function verifyDue(req, opts = {}) {
|
|
331
|
+
const key = secretKey(opts.secret ?? process.env.ARGENTIC_WEBHOOK_SECRET);
|
|
332
|
+
const { body, get } = await readDue(req);
|
|
333
|
+
const id = get('webhook-id');
|
|
334
|
+
const stamp = get('webhook-timestamp');
|
|
335
|
+
const sigs = get('webhook-signature');
|
|
336
|
+
if (!id || !stamp || !sigs)
|
|
337
|
+
throw new WebhookVerificationError('missing webhook-id, webhook-timestamp or webhook-signature header', 'missing_header');
|
|
338
|
+
if (!/^\d{1,12}$/.test(stamp))
|
|
339
|
+
throw new WebhookVerificationError('webhook-timestamp is not a number of seconds', 'bad_timestamp');
|
|
340
|
+
const nowS = (opts.now ?? Date.now)() / 1000;
|
|
341
|
+
if (Math.abs(nowS - Number(stamp)) > (opts.toleranceSeconds ?? 300))
|
|
342
|
+
throw new WebhookVerificationError('webhook-timestamp is outside the allowed window', 'stale');
|
|
343
|
+
const expected = createHmac('sha256', key).update(`${id}.${stamp}.${body}`).digest();
|
|
344
|
+
let ok = false;
|
|
345
|
+
for (const entry of sigs.split(/\s+/)) {
|
|
346
|
+
const [version, sig] = entry.split(',');
|
|
347
|
+
if (version !== 'v1' || !sig)
|
|
348
|
+
continue;
|
|
349
|
+
const given = Buffer.from(sig, 'base64');
|
|
350
|
+
if (given.length === expected.length && timingSafeEqual(given, expected))
|
|
351
|
+
ok = true;
|
|
352
|
+
}
|
|
353
|
+
if (!ok)
|
|
354
|
+
throw new WebhookVerificationError('no webhook-signature matches', 'bad_signature');
|
|
355
|
+
let notice;
|
|
356
|
+
try {
|
|
357
|
+
notice = JSON.parse(body);
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
throw new WebhookVerificationError('the notice body is not JSON', 'bad_body');
|
|
361
|
+
}
|
|
362
|
+
if (notice?.type !== 'agent.due' || typeof notice.agent !== 'string' || !notice.agent)
|
|
363
|
+
throw new WebhookVerificationError('not an agent.due notice', 'bad_body');
|
|
364
|
+
return { notice, id };
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Verify a due notice, then wake the agent for what fell due, on your own model key (the notice carries none).
|
|
368
|
+
* The wake's idempotency key comes from the notice's webhook-id, so a notice delivered again replays the first wake's answer.
|
|
369
|
+
* Throws WebhookVerificationError before any call is made if the notice is not valid.
|
|
370
|
+
*/
|
|
371
|
+
export async function handleDue(req, opts = {}) {
|
|
372
|
+
const { notice, id } = await verifyDue(req, opts);
|
|
373
|
+
const body = { fire_due: true, model: opts.model ?? freeModel };
|
|
374
|
+
if (notice.memory != null)
|
|
375
|
+
body.memory = notice.memory;
|
|
376
|
+
const wake = await (opts.client ?? new Argentic()).wake(notice.agent, body, safeKey(id));
|
|
377
|
+
return { notice, wake };
|
|
378
|
+
}
|
|
379
|
+
/** handleDue as a route handler's answer: 200 woken, 401 not verified, 400 unreadable body, 500 secret not set up, 502 the wake failed (so the notice is sent again). */
|
|
380
|
+
export async function handleDueResponse(req, opts = {}) {
|
|
381
|
+
try {
|
|
382
|
+
const { notice, wake } = await handleDue(req, opts);
|
|
383
|
+
return Response.json({ agent: notice.agent, wake_id: wake.wake_id, status: wake.status });
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
if (e instanceof WebhookVerificationError) {
|
|
387
|
+
const status = e.code === 'bad_body' ? 400 : e.code === 'bad_secret' ? 500 : 401;
|
|
388
|
+
return Response.json({ error: e.code }, { status });
|
|
389
|
+
}
|
|
390
|
+
return Response.json({ error: 'wake_failed' }, { status: 502 });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
export class Ledger {
|
|
394
|
+
path;
|
|
395
|
+
entries = {};
|
|
396
|
+
wakes = {};
|
|
397
|
+
/**
|
|
398
|
+
* A missing file starts an empty ledger. A file that cannot be read or parsed is set aside as `<path>.corrupt` and `onCorrupt`
|
|
399
|
+
* is told, because an empty ledger means finished actions may run again. `onCorrupt` defaults to console.warn.
|
|
400
|
+
*/
|
|
401
|
+
constructor(path, onCorrupt = (m) => console.warn(m)) {
|
|
402
|
+
this.path = path;
|
|
403
|
+
let text;
|
|
404
|
+
try {
|
|
405
|
+
text = readFileSync(path, 'utf8');
|
|
406
|
+
}
|
|
407
|
+
catch (e) {
|
|
408
|
+
if (e.code === 'ENOENT')
|
|
409
|
+
return;
|
|
410
|
+
onCorrupt(`argentic: cannot read the ledger ${path} (${e.message}); starting empty, so finished actions may run again`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const saved = JSON.parse(text);
|
|
415
|
+
if (saved === null || typeof saved !== 'object' || Array.isArray(saved))
|
|
416
|
+
throw new Error('not a JSON object');
|
|
417
|
+
this.entries = saved.entries ?? {};
|
|
418
|
+
this.wakes = saved.wakes ?? {};
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
this.entries = {};
|
|
422
|
+
this.wakes = {};
|
|
423
|
+
let kept = '';
|
|
424
|
+
try {
|
|
425
|
+
renameSync(path, `${path}.corrupt`);
|
|
426
|
+
kept = `, the old file is kept as ${path}.corrupt`;
|
|
427
|
+
}
|
|
428
|
+
catch { }
|
|
429
|
+
onCorrupt(`argentic: the ledger ${path} is not valid (${e.message}); starting empty, so finished actions may run again${kept}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
get(key) {
|
|
433
|
+
return this.entries[key];
|
|
434
|
+
}
|
|
435
|
+
set(key, entry) {
|
|
436
|
+
this.entries[key] = entry;
|
|
437
|
+
this.save();
|
|
438
|
+
}
|
|
439
|
+
/** The stopped wake answered for this idempotency key, if one was kept. */
|
|
440
|
+
getWake(key) {
|
|
441
|
+
return this.wakes[key];
|
|
442
|
+
}
|
|
443
|
+
setWake(key, wake) {
|
|
444
|
+
this.wakes[key] = wake;
|
|
445
|
+
this.save();
|
|
446
|
+
}
|
|
447
|
+
/** Write temp file, then rename, so a crash never leaves half a ledger. */
|
|
448
|
+
save() {
|
|
449
|
+
mkdirSync(dirname(this.path) || '.', { recursive: true });
|
|
450
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
451
|
+
writeFileSync(tmp, JSON.stringify(Object.keys(this.wakes).length ? { entries: this.entries, wakes: this.wakes } : { entries: this.entries }, null, 2));
|
|
452
|
+
renameSync(tmp, this.path);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// runDurable
|
|
457
|
+
/**
|
|
458
|
+
* Throw this from a tool when the outcome is not known yet and the same call is safe to try again:
|
|
459
|
+
* a network drop, a timeout, a provider that is down. runDurable does not write it to the ledger,
|
|
460
|
+
* so it stops the run. Run it again and the action is asked for again with the same key.
|
|
461
|
+
*/
|
|
462
|
+
export class RetryLater extends Error {
|
|
463
|
+
cause;
|
|
464
|
+
constructor(message, cause) {
|
|
465
|
+
super(message);
|
|
466
|
+
this.cause = cause;
|
|
467
|
+
this.name = 'RetryLater';
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const safeKey = (s) => s.replace(/[^A-Za-z0-9\-_.:]/g, '-').slice(0, 128);
|
|
471
|
+
/**
|
|
472
|
+
* A wake stopped by its turn or time limit leaves the agent mid-task. Its actions have not been answered, so the next
|
|
473
|
+
* wake sends their results and the agent carries on from there with them, instead of the same key resuming it blind.
|
|
474
|
+
*/
|
|
475
|
+
const carriesOn = (s) => s === 'turn_cap' || s === 'time_cap';
|
|
476
|
+
export async function runDurable(opts) {
|
|
477
|
+
const { client, agent, tools } = opts;
|
|
478
|
+
const log = opts.log ?? ((line) => console.log(line));
|
|
479
|
+
const maxSteps = opts.maxSteps ?? 8;
|
|
480
|
+
const model = opts.model ?? freeModel;
|
|
481
|
+
const ledger = new Ledger(opts.ledgerPath);
|
|
482
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
483
|
+
const decls = tools.map(({ name, description, parameters, once_per }) => {
|
|
484
|
+
const schema = toArgenticSchema(parameters);
|
|
485
|
+
if (once_per !== undefined)
|
|
486
|
+
checkOncePer(name, once_per, schema);
|
|
487
|
+
return { name, description: description || name, parameters: schema, ...(once_per !== undefined && { once_per }) };
|
|
488
|
+
});
|
|
489
|
+
const wakes = [];
|
|
490
|
+
let message = opts.task;
|
|
491
|
+
let reply = '';
|
|
492
|
+
let spentUsd = 0;
|
|
493
|
+
let status = 'max_steps';
|
|
494
|
+
let steps = 0;
|
|
495
|
+
let executed = 0;
|
|
496
|
+
const structured = opts.structuredResults ?? true;
|
|
497
|
+
let pending = [];
|
|
498
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
499
|
+
const key = safeKey(`${opts.runId}.${step}`);
|
|
500
|
+
const body = { message, model, tools: decls };
|
|
501
|
+
if (structured && step > 0 && pending.length)
|
|
502
|
+
body.results = pending;
|
|
503
|
+
opts.onWakeStart?.({ step });
|
|
504
|
+
if (opts.system)
|
|
505
|
+
body.system = opts.system;
|
|
506
|
+
if (opts.limits)
|
|
507
|
+
body.limits = opts.limits;
|
|
508
|
+
// A stopped wake kept in the ledger is not asked again: its actions get their results under the next key.
|
|
509
|
+
const kept = ledger.getWake(key);
|
|
510
|
+
const res = kept ? { ...kept, idempotentReplay: true } : await client.wake(agent, body, key);
|
|
511
|
+
if (!kept && carriesOn(res.status) && res.actions?.length) {
|
|
512
|
+
const { idempotentReplay: _, ...answer } = res;
|
|
513
|
+
ledger.setWake(key, answer);
|
|
514
|
+
}
|
|
515
|
+
steps = step + 1;
|
|
516
|
+
wakes.push(res.wake_id);
|
|
517
|
+
reply = res.reply ?? '';
|
|
518
|
+
spentUsd += res.spent_usd ?? 0;
|
|
519
|
+
status = res.status;
|
|
520
|
+
log(`step ${step}: wake ${res.wake_id} ${res.status}${kept ? ' (stopped answer kept in the ledger)' : res.idempotentReplay ? ' (stored answer replayed)' : ''}`);
|
|
521
|
+
opts.onWake?.({ step, wakeId: res.wake_id, status: res.status, replayed: !!res.idempotentReplay });
|
|
522
|
+
const actions = res.actions ?? [];
|
|
523
|
+
const results = [];
|
|
524
|
+
for (const action of actions) {
|
|
525
|
+
const ledgerKey = `${agent}:${action.seq}`;
|
|
526
|
+
const done = ledger.get(ledgerKey);
|
|
527
|
+
if (done) {
|
|
528
|
+
log(`skip seq ${action.seq} ${action.tool}: already done before`);
|
|
529
|
+
opts.onSkip?.({ tool: action.tool, seq: action.seq });
|
|
530
|
+
results.push({ tool: action.tool, seq: action.seq, result: done.result });
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const tool = byName.get(action.tool);
|
|
534
|
+
let result;
|
|
535
|
+
if (!tool) {
|
|
536
|
+
result = { error: 'unknown tool' };
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
try {
|
|
540
|
+
result = await tool.execute(action.args ?? {}, { seq: action.seq, idempotencyKey: ledgerKey });
|
|
541
|
+
}
|
|
542
|
+
catch (e) {
|
|
543
|
+
if (e instanceof RetryLater)
|
|
544
|
+
throw e;
|
|
545
|
+
result = { error: e instanceof Error ? e.message : String(e) };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
ledger.set(ledgerKey, { tool: action.tool, args: action.args ?? {}, result: result ?? null, at: new Date().toISOString() });
|
|
549
|
+
executed++;
|
|
550
|
+
log(`ran seq ${action.seq} ${action.tool}`);
|
|
551
|
+
results.push({ tool: action.tool, seq: action.seq, result: ledger.get(ledgerKey).result });
|
|
552
|
+
opts.onAction?.({ tool: action.tool, seq: action.seq, executed });
|
|
553
|
+
if (opts.crashAfterActions && executed >= opts.crashAfterActions) {
|
|
554
|
+
log(`simulating a crash after ${executed} action(s)`);
|
|
555
|
+
process.kill(process.pid, 'SIGKILL');
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (actions.length === 0 || !(res.status === 'done' || carriesOn(res.status)))
|
|
559
|
+
break;
|
|
560
|
+
message = 'Results of the tools you asked for:\n' + JSON.stringify(results);
|
|
561
|
+
pending = results.map(({ seq, result }) => ({ seq, result }));
|
|
562
|
+
}
|
|
563
|
+
return { reply, wakes, steps, status, spentUsd };
|
|
564
|
+
}
|
package/dist/cli.d.ts
ADDED