@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/tools.js
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// Retry-safe tools for real services: Stripe, Resend, SMTP, Slack, GitHub and a generic HTTP POST.
|
|
2
|
+
// Every action Argentic hands you carries a stable key (`agent:seq`). These tools use it so a cut-off
|
|
3
|
+
// action can be tried again safely: the provider's own idempotency where it has one, and for the three
|
|
4
|
+
// that have none (SMTP, Slack, GitHub) a marker the tool looks for before it sends again.
|
|
5
|
+
// No dependencies, Node 22+. What each provider can and cannot promise is in TOOLS.md.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import * as net from 'node:net';
|
|
10
|
+
import * as tls from 'node:tls';
|
|
11
|
+
import { RetryLater } from "./argentic.js";
|
|
12
|
+
class IntentLog {
|
|
13
|
+
path;
|
|
14
|
+
entries = {};
|
|
15
|
+
constructor(path) {
|
|
16
|
+
this.path = path;
|
|
17
|
+
try {
|
|
18
|
+
this.entries = JSON.parse(readFileSync(path, 'utf8')).entries ?? {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
this.entries = {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
get(key) {
|
|
25
|
+
return this.entries[key];
|
|
26
|
+
}
|
|
27
|
+
set(key, e) {
|
|
28
|
+
this.entries[key] = e;
|
|
29
|
+
mkdirSync(dirname(this.path) || '.', { recursive: true });
|
|
30
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
31
|
+
writeFileSync(tmp, JSON.stringify({ entries: this.entries }, null, 2));
|
|
32
|
+
renameSync(tmp, this.path);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Shared pieces
|
|
37
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
/** A key a provider accepts (Stripe allows 255 characters, Resend 256). */
|
|
39
|
+
export function providerKey(key) {
|
|
40
|
+
return key.length <= 200 ? key : 'sha256:' + createHash('sha256').update(key).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
class Runtime {
|
|
43
|
+
log;
|
|
44
|
+
doFetch;
|
|
45
|
+
sleep;
|
|
46
|
+
tries;
|
|
47
|
+
backoffMs;
|
|
48
|
+
constructor(o) {
|
|
49
|
+
this.log = new IntentLog(o.statePath ?? '.argentic-intents.json');
|
|
50
|
+
this.doFetch = o.fetch ?? fetch;
|
|
51
|
+
this.sleep = o.sleep ?? wait;
|
|
52
|
+
this.tries = Math.max(1, o.tries ?? 4);
|
|
53
|
+
this.backoffMs = o.backoffMs ?? [500, 1500, 4000];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Run one action once. A finished intent returns its stored result. `attempt` tells the sender
|
|
57
|
+
* whether an earlier attempt may have landed (a restart that found "started", or a retry in this process).
|
|
58
|
+
*/
|
|
59
|
+
async once(ctx, tool, send) {
|
|
60
|
+
const prior = this.log.get(ctx.idempotencyKey);
|
|
61
|
+
if (prior?.state === 'done')
|
|
62
|
+
return prior.result;
|
|
63
|
+
const startedAt = prior?.at ?? new Date().toISOString();
|
|
64
|
+
if (!prior)
|
|
65
|
+
this.log.set(ctx.idempotencyKey, { state: 'started', tool, at: startedAt });
|
|
66
|
+
const result = await send({ maybeSent: prior?.state === 'started', startedAt });
|
|
67
|
+
this.log.set(ctx.idempotencyKey, { state: 'done', tool, at: startedAt, result });
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
/** Same request again after a network error, 429 or 5xx, then RetryLater. `fn` returns a Response or throws. */
|
|
71
|
+
async withRetry(what, fn, retryOn5xx = true) {
|
|
72
|
+
let last = '';
|
|
73
|
+
for (let attempt = 0; attempt < this.tries; attempt++) {
|
|
74
|
+
if (attempt > 0)
|
|
75
|
+
await this.sleep(this.backoffMs[Math.min(attempt - 1, this.backoffMs.length - 1)] ?? 0);
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await fn(attempt);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
last = e instanceof Error ? e.message : String(e);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (res.status === 429 || res.status === 409 || (retryOn5xx && res.status >= 500)) {
|
|
85
|
+
last = `HTTP ${res.status}`;
|
|
86
|
+
const ra = Number(res.headers.get('retry-after'));
|
|
87
|
+
if (ra > 0)
|
|
88
|
+
await this.sleep(Math.min(ra, 30) * 1000);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
return res;
|
|
92
|
+
}
|
|
93
|
+
throw new RetryLater(`${what}: no answer after ${this.tries} tries (${last}). Run again to try the same action with the same key.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* For a provider with no idempotency: a network error or 5xx may have landed, so before posting again look for
|
|
98
|
+
* the action by its marker. `lookup` returns the found item, undefined for "not there", or null for "cannot check".
|
|
99
|
+
* A 429 was not processed, so it is simply tried again.
|
|
100
|
+
*/
|
|
101
|
+
async function guarded(rt, what, policy, lookupFirst, post, lookup) {
|
|
102
|
+
let maybe = lookupFirst;
|
|
103
|
+
let last = '';
|
|
104
|
+
for (let attempt = 0; attempt < rt.tries; attempt++) {
|
|
105
|
+
if (attempt > 0)
|
|
106
|
+
await rt.sleep(rt.backoffMs[Math.min(attempt - 1, rt.backoffMs.length - 1)] ?? 0);
|
|
107
|
+
if (maybe) {
|
|
108
|
+
const found = await lookup();
|
|
109
|
+
if (found)
|
|
110
|
+
return { hit: found };
|
|
111
|
+
if (found === null && policy === 'hold')
|
|
112
|
+
return { unknown: true };
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const res = await post();
|
|
116
|
+
if (res.status === 429) {
|
|
117
|
+
const ra = Number(res.headers.get('retry-after'));
|
|
118
|
+
if (ra > 0)
|
|
119
|
+
await rt.sleep(Math.min(ra, 30) * 1000);
|
|
120
|
+
last = 'HTTP 429';
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (res.status >= 500) {
|
|
124
|
+
maybe = true;
|
|
125
|
+
last = `HTTP ${res.status}`;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
return { res };
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
maybe = true;
|
|
132
|
+
last = e instanceof Error ? e.message : String(e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new RetryLater(`${what}: no answer after ${rt.tries} tries (${last}). Run again: it looks for the action before sending it again.`);
|
|
136
|
+
}
|
|
137
|
+
const bodyOf = async (res) => {
|
|
138
|
+
const text = await res.text();
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(text);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return { raw: text.slice(0, 500) };
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const str = { type: 'string' };
|
|
147
|
+
const ambiguousNote = (what) => `${what} may or may not have been delivered before the process stopped, and it cannot be checked. It was not sent again. Check by hand before asking for it again.`;
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Stripe: native idempotency (Idempotency-Key header).
|
|
150
|
+
function stripeTools(rt, o) {
|
|
151
|
+
const base = (o.baseUrl ?? 'https://api.stripe.com').replace(/\/+$/, '');
|
|
152
|
+
const call = async (ctx, path, params) => {
|
|
153
|
+
const res = await rt.withRetry('stripe', () => rt.doFetch(base + path, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: {
|
|
156
|
+
Authorization: `Bearer ${o.secretKey}`,
|
|
157
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
158
|
+
'Idempotency-Key': providerKey(ctx.idempotencyKey),
|
|
159
|
+
},
|
|
160
|
+
body: new URLSearchParams(params).toString(),
|
|
161
|
+
}),
|
|
162
|
+
// Stripe stores a 500 under the key and replays it, so asking again cannot change it.
|
|
163
|
+
false);
|
|
164
|
+
const json = await bodyOf(res);
|
|
165
|
+
const replayed = res.headers.get('idempotent-replayed') === 'true';
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
return { ok: false, status: res.status, error: json?.error?.message ?? json?.error?.code ?? 'request failed', code: json?.error?.code, replayed };
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, id: json.id, status: json.status, amount: json.amount, currency: json.currency, replayed };
|
|
170
|
+
};
|
|
171
|
+
const capped = (amount) => {
|
|
172
|
+
if (!Number.isInteger(amount) || amount <= 0)
|
|
173
|
+
return 'amount must be a positive whole number of minor units (cents)';
|
|
174
|
+
if (o.maxAmount !== undefined && amount > o.maxAmount)
|
|
175
|
+
return `amount is over the limit of ${o.maxAmount}`;
|
|
176
|
+
return undefined;
|
|
177
|
+
};
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
name: 'stripe_refund',
|
|
181
|
+
description: 'Refund a Stripe payment. Give the payment_intent id (pi_...) and, for a partial refund, the amount in cents.',
|
|
182
|
+
parameters: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: { payment_intent: str, amount: { type: 'integer', description: 'cents; omit to refund the whole payment' }, reason: { type: 'string', enum: ['duplicate', 'fraudulent', 'requested_by_customer'] } },
|
|
185
|
+
required: ['payment_intent'],
|
|
186
|
+
},
|
|
187
|
+
execute: (args, ctx) => rt.once(ctx, 'stripe_refund', async () => {
|
|
188
|
+
if (args.amount !== undefined) {
|
|
189
|
+
const bad = capped(args.amount);
|
|
190
|
+
if (bad)
|
|
191
|
+
return { ok: false, error: bad };
|
|
192
|
+
}
|
|
193
|
+
else if (o.maxAmount !== undefined) {
|
|
194
|
+
return { ok: false, error: 'a full refund needs an amount while a limit is set' };
|
|
195
|
+
}
|
|
196
|
+
const p = { payment_intent: String(args.payment_intent) };
|
|
197
|
+
if (args.amount !== undefined)
|
|
198
|
+
p.amount = String(args.amount);
|
|
199
|
+
if (args.reason)
|
|
200
|
+
p.reason = String(args.reason);
|
|
201
|
+
return call(ctx, '/v1/refunds', p);
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'stripe_charge',
|
|
206
|
+
description: 'Charge a saved Stripe customer off-session. Give the customer id (cus_...), a payment_method id (pm_...), the amount in cents and a 3-letter currency.',
|
|
207
|
+
parameters: {
|
|
208
|
+
type: 'object',
|
|
209
|
+
properties: { customer: str, payment_method: str, amount: { type: 'integer' }, currency: str, description: str },
|
|
210
|
+
required: ['customer', 'payment_method', 'amount', 'currency'],
|
|
211
|
+
},
|
|
212
|
+
execute: (args, ctx) => rt.once(ctx, 'stripe_charge', async () => {
|
|
213
|
+
const bad = capped(args.amount);
|
|
214
|
+
if (bad)
|
|
215
|
+
return { ok: false, error: bad };
|
|
216
|
+
const p = {
|
|
217
|
+
customer: String(args.customer),
|
|
218
|
+
payment_method: String(args.payment_method),
|
|
219
|
+
amount: String(args.amount),
|
|
220
|
+
currency: String(args.currency).toLowerCase(),
|
|
221
|
+
confirm: 'true',
|
|
222
|
+
off_session: 'true',
|
|
223
|
+
};
|
|
224
|
+
if (args.description)
|
|
225
|
+
p.description = String(args.description);
|
|
226
|
+
return call(ctx, '/v1/payment_intents', p);
|
|
227
|
+
}),
|
|
228
|
+
},
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Resend: native idempotency (Idempotency-Key header).
|
|
233
|
+
function resendTools(rt, o, name) {
|
|
234
|
+
const base = (o.baseUrl ?? 'https://api.resend.com').replace(/\/+$/, '');
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
name,
|
|
238
|
+
description: 'Send an email. Plain text in body; add html for a formatted version.',
|
|
239
|
+
parameters: { type: 'object', properties: { to: str, subject: str, body: str, html: str, reply_to: str }, required: ['to', 'subject', 'body'] },
|
|
240
|
+
execute: (args, ctx) => rt.once(ctx, name, async () => {
|
|
241
|
+
const payload = { from: o.from, to: [String(args.to)], subject: String(args.subject), text: String(args.body) };
|
|
242
|
+
if (args.html)
|
|
243
|
+
payload.html = String(args.html);
|
|
244
|
+
if (args.reply_to)
|
|
245
|
+
payload.reply_to = String(args.reply_to);
|
|
246
|
+
const res = await rt.withRetry('resend', () => rt.doFetch(base + '/emails', {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { Authorization: `Bearer ${o.apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': providerKey(ctx.idempotencyKey) },
|
|
249
|
+
body: JSON.stringify(payload),
|
|
250
|
+
}));
|
|
251
|
+
const json = await bodyOf(res);
|
|
252
|
+
if (!res.ok)
|
|
253
|
+
return { ok: false, status: res.status, error: json?.message ?? json?.name ?? 'request failed' };
|
|
254
|
+
return { ok: true, id: json.id, to: args.to };
|
|
255
|
+
}),
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// SMTP: no idempotency exists. A deterministic Message-ID, an intent written first, and a hold on the ambiguous case.
|
|
261
|
+
class SmtpError extends Error {
|
|
262
|
+
code;
|
|
263
|
+
handedOver;
|
|
264
|
+
constructor(message, code, handedOver) {
|
|
265
|
+
super(message);
|
|
266
|
+
this.code = code;
|
|
267
|
+
this.handedOver = handedOver;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const crlf = (s) => s.replace(/\r?\n/g, '\r\n');
|
|
271
|
+
const header = (s) => s.replace(/[\r\n]+/g, ' ');
|
|
272
|
+
async function smtpSend(o, msg) {
|
|
273
|
+
const port = o.port ?? (o.secure ? 465 : 587);
|
|
274
|
+
let sock = o.secure
|
|
275
|
+
? tls.connect({ host: o.host, port, rejectUnauthorized: !o.insecureTls })
|
|
276
|
+
: net.connect({ host: o.host, port });
|
|
277
|
+
sock.setTimeout(30_000);
|
|
278
|
+
let buf = '';
|
|
279
|
+
let handedOver = false;
|
|
280
|
+
const waiters = [];
|
|
281
|
+
let failure;
|
|
282
|
+
const onData = (d) => {
|
|
283
|
+
buf += d.toString('utf8');
|
|
284
|
+
for (;;) {
|
|
285
|
+
const m = buf.match(/^(?:\d{3}-[^\n]*\n)*\d{3} [^\n]*\n/);
|
|
286
|
+
if (!m)
|
|
287
|
+
return;
|
|
288
|
+
buf = buf.slice(m[0].length);
|
|
289
|
+
waiters.shift()?.(m[0]);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const fail = (e) => {
|
|
293
|
+
failure = failure ?? e;
|
|
294
|
+
while (waiters.length)
|
|
295
|
+
waiters.shift()('');
|
|
296
|
+
};
|
|
297
|
+
const attach = (s) => {
|
|
298
|
+
s.on('data', onData);
|
|
299
|
+
s.on('error', fail);
|
|
300
|
+
s.on('timeout', () => fail(new Error('smtp timeout')));
|
|
301
|
+
s.on('close', () => fail(new Error('smtp connection closed')));
|
|
302
|
+
};
|
|
303
|
+
attach(sock);
|
|
304
|
+
const reply = () => new Promise((resolve, reject) => {
|
|
305
|
+
if (failure)
|
|
306
|
+
return reject(failure);
|
|
307
|
+
waiters.push((line) => (failure && !line ? reject(failure) : resolve(line)));
|
|
308
|
+
});
|
|
309
|
+
const expect = async (ok) => {
|
|
310
|
+
const r = await reply();
|
|
311
|
+
const code = Number(r.slice(0, 3));
|
|
312
|
+
if (!ok.includes(code))
|
|
313
|
+
throw new SmtpError(`smtp ${r.trim().slice(0, 200)}`, code, handedOver);
|
|
314
|
+
return r;
|
|
315
|
+
};
|
|
316
|
+
const cmd = async (line, ok) => {
|
|
317
|
+
sock.write(line + '\r\n');
|
|
318
|
+
return expect(ok);
|
|
319
|
+
};
|
|
320
|
+
try {
|
|
321
|
+
await expect([220]);
|
|
322
|
+
let ehlo = await cmd(`EHLO argentic.local`, [250]);
|
|
323
|
+
if (!o.secure && /STARTTLS/i.test(ehlo)) {
|
|
324
|
+
await cmd('STARTTLS', [220]);
|
|
325
|
+
sock.removeAllListeners('data');
|
|
326
|
+
const upgraded = tls.connect({ socket: sock, host: o.host, rejectUnauthorized: !o.insecureTls });
|
|
327
|
+
sock = upgraded;
|
|
328
|
+
attach(upgraded);
|
|
329
|
+
buf = '';
|
|
330
|
+
ehlo = await cmd(`EHLO argentic.local`, [250]);
|
|
331
|
+
}
|
|
332
|
+
if (o.user) {
|
|
333
|
+
const token = Buffer.from(`\0${o.user}\0${o.pass ?? ''}`).toString('base64');
|
|
334
|
+
await cmd(`AUTH PLAIN ${token}`, [235]);
|
|
335
|
+
}
|
|
336
|
+
const from = o.from.match(/<([^>]+)>/)?.[1] ?? o.from;
|
|
337
|
+
await cmd(`MAIL FROM:<${header(from)}>`, [250]);
|
|
338
|
+
await cmd(`RCPT TO:<${header(msg.to)}>`, [250, 251]);
|
|
339
|
+
await cmd('DATA', [354]);
|
|
340
|
+
const text = [
|
|
341
|
+
`From: ${header(o.from)}`,
|
|
342
|
+
`To: ${header(msg.to)}`,
|
|
343
|
+
`Subject: ${header(msg.subject)}`,
|
|
344
|
+
`Message-ID: ${msg.messageId}`,
|
|
345
|
+
`X-Argentic-Idempotency-Key: ${header(msg.key)}`,
|
|
346
|
+
`Date: ${new Date().toUTCString()}`,
|
|
347
|
+
'MIME-Version: 1.0',
|
|
348
|
+
'Content-Type: text/plain; charset=utf-8',
|
|
349
|
+
'',
|
|
350
|
+
crlf(msg.body).replace(/^\./gm, '..'),
|
|
351
|
+
].join('\r\n');
|
|
352
|
+
// From here on a cut-off connection leaves it unknown whether the server queued the message.
|
|
353
|
+
handedOver = true;
|
|
354
|
+
sock.write(text + '\r\n.\r\n');
|
|
355
|
+
await expect([250]);
|
|
356
|
+
handedOver = false;
|
|
357
|
+
sock.write('QUIT\r\n');
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
if (e instanceof SmtpError)
|
|
361
|
+
throw e;
|
|
362
|
+
throw new SmtpError(e instanceof Error ? e.message : String(e), undefined, handedOver);
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
sock.destroy();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function smtpTools(rt, o, name) {
|
|
369
|
+
const policy = o.onAmbiguous ?? 'hold';
|
|
370
|
+
const domain = (o.from.match(/@([^>\s]+)/)?.[1] ?? 'argentic.local').replace(/[^A-Za-z0-9.-]/g, '');
|
|
371
|
+
return [
|
|
372
|
+
{
|
|
373
|
+
name,
|
|
374
|
+
description: 'Send a plain-text email.',
|
|
375
|
+
parameters: { type: 'object', properties: { to: str, subject: str, body: str }, required: ['to', 'subject', 'body'] },
|
|
376
|
+
execute: (args, ctx) => rt.once(ctx, name, async ({ maybeSent }) => {
|
|
377
|
+
if (maybeSent && policy === 'hold') {
|
|
378
|
+
return { ok: false, outcome: 'unknown', note: ambiguousNote('This email') };
|
|
379
|
+
}
|
|
380
|
+
const messageId = `<${createHash('sha256').update(ctx.idempotencyKey).digest('hex').slice(0, 32)}@${domain}>`;
|
|
381
|
+
for (let attempt = 0; attempt < rt.tries; attempt++) {
|
|
382
|
+
if (attempt > 0)
|
|
383
|
+
await rt.sleep(rt.backoffMs[Math.min(attempt - 1, rt.backoffMs.length - 1)] ?? 0);
|
|
384
|
+
try {
|
|
385
|
+
await smtpSend(o, { to: String(args.to), subject: String(args.subject), body: String(args.body), messageId, key: ctx.idempotencyKey });
|
|
386
|
+
return { ok: true, to: args.to, messageId };
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
const err = e;
|
|
390
|
+
if (err.code !== undefined && err.code >= 500)
|
|
391
|
+
return { ok: false, error: err.message };
|
|
392
|
+
if (err.handedOver) {
|
|
393
|
+
// The server may have queued it. Same Message-ID lets a receiver spot a copy, but nothing guarantees it.
|
|
394
|
+
if (policy === 'hold')
|
|
395
|
+
return { ok: false, outcome: 'unknown', note: ambiguousNote('This email') };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
throw new RetryLater('smtp: could not reach the server. Run again to try the same email.');
|
|
400
|
+
}),
|
|
401
|
+
},
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Slack: no idempotency. The key rides in message metadata; before any repeat the tool looks for it.
|
|
406
|
+
function slackTools(rt, o) {
|
|
407
|
+
const base = (o.baseUrl ?? 'https://slack.com/api').replace(/\/+$/, '');
|
|
408
|
+
const policy = o.onAmbiguous ?? 'hold';
|
|
409
|
+
const call = (method, payload) => rt.doFetch(`${base}/${method}`, {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers: { Authorization: `Bearer ${o.token}`, 'Content-Type': 'application/json; charset=utf-8' },
|
|
412
|
+
body: JSON.stringify(payload),
|
|
413
|
+
});
|
|
414
|
+
return [
|
|
415
|
+
{
|
|
416
|
+
name: 'slack_post_message',
|
|
417
|
+
description: 'Post a message to a Slack channel (channel id, such as C0123ABCD). thread_ts replies in a thread.',
|
|
418
|
+
parameters: { type: 'object', properties: { channel: str, text: str, thread_ts: str }, required: ['channel', 'text'] },
|
|
419
|
+
execute: (args, ctx) => rt.once(ctx, 'slack_post_message', async ({ maybeSent, startedAt }) => {
|
|
420
|
+
const channel = String(args.channel);
|
|
421
|
+
if (o.channels && !o.channels.includes(channel))
|
|
422
|
+
return { ok: false, error: `channel ${channel} is not allowed` };
|
|
423
|
+
const threadTs = args.thread_ts ? String(args.thread_ts) : undefined;
|
|
424
|
+
const lookup = async () => {
|
|
425
|
+
const oldest = String(Math.floor(Date.parse(startedAt) / 1000) - 60);
|
|
426
|
+
try {
|
|
427
|
+
const res = await call(threadTs ? 'conversations.replies' : 'conversations.history', { channel, ...(threadTs ? { ts: threadTs } : {}), oldest, limit: 100, include_all_metadata: true });
|
|
428
|
+
const r = await bodyOf(res);
|
|
429
|
+
if (!r?.ok)
|
|
430
|
+
return null;
|
|
431
|
+
return (r.messages ?? []).find((m) => m?.metadata?.event_payload?.key === ctx.idempotencyKey);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const post = () => call('chat.postMessage', {
|
|
438
|
+
channel,
|
|
439
|
+
text: String(args.text),
|
|
440
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
441
|
+
metadata: { event_type: 'argentic_action', event_payload: { key: ctx.idempotencyKey } },
|
|
442
|
+
});
|
|
443
|
+
const out = await guarded(rt, 'slack', policy, maybeSent, post, lookup);
|
|
444
|
+
if ('unknown' in out)
|
|
445
|
+
return { ok: false, outcome: 'unknown', note: ambiguousNote('This Slack message') };
|
|
446
|
+
if ('hit' in out)
|
|
447
|
+
return { ok: true, ts: out.hit.ts, channel, found: true };
|
|
448
|
+
const r = await bodyOf(out.res);
|
|
449
|
+
if (!r?.ok)
|
|
450
|
+
return { ok: false, error: r?.error ?? `HTTP ${out.res.status}` };
|
|
451
|
+
return { ok: true, ts: r.ts, channel };
|
|
452
|
+
}),
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// GitHub: no idempotency. A hidden marker in the body; before any repeat the tool lists and looks for it.
|
|
458
|
+
const marker = (key) => `<!-- argentic-key:${key.replace(/--/g, '-')} -->`;
|
|
459
|
+
function githubTools(rt, o) {
|
|
460
|
+
const base = (o.baseUrl ?? 'https://api.github.com').replace(/\/+$/, '');
|
|
461
|
+
const policy = o.onAmbiguous ?? 'hold';
|
|
462
|
+
const h = { Authorization: `Bearer ${o.token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json' };
|
|
463
|
+
const repoOk = (repo) => /^[\w.-]+\/[\w.-]+$/.test(repo) && (!o.repos || o.repos.includes(repo));
|
|
464
|
+
/** Scan newest-first pages of a list for the marker. Lists are read straight from the database, unlike search. */
|
|
465
|
+
const scan = async (url, mk) => {
|
|
466
|
+
try {
|
|
467
|
+
for (let page = 1; page <= 3; page++) {
|
|
468
|
+
const res = await rt.doFetch(`${url}${url.includes('?') ? '&' : '?'}per_page=100&page=${page}`, { headers: h });
|
|
469
|
+
if (!res.ok)
|
|
470
|
+
return null;
|
|
471
|
+
const items = (await res.json());
|
|
472
|
+
const hit = items.find((i) => typeof i.body === 'string' && i.body.includes(mk) && !i.pull_request);
|
|
473
|
+
if (hit)
|
|
474
|
+
return hit;
|
|
475
|
+
if (items.length < 100)
|
|
476
|
+
return undefined;
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
const run = (name, listUrl, createUrl, makePayload) => (args, ctx) => rt.once(ctx, name, async ({ maybeSent, startedAt }) => {
|
|
485
|
+
const repo = String(args.repo);
|
|
486
|
+
if (!repoOk(repo))
|
|
487
|
+
return { ok: false, error: `repo ${repo} is not allowed` };
|
|
488
|
+
const mk = marker(ctx.idempotencyKey);
|
|
489
|
+
const since = new Date(Date.parse(startedAt) - 60_000).toISOString();
|
|
490
|
+
const out = await guarded(rt, 'github', policy, maybeSent, () => rt.doFetch(createUrl(args), { method: 'POST', headers: h, body: JSON.stringify(makePayload(args, mk)) }), () => scan(listUrl(args, since), mk));
|
|
491
|
+
if ('unknown' in out)
|
|
492
|
+
return { ok: false, outcome: 'unknown', note: ambiguousNote('This GitHub write') };
|
|
493
|
+
if ('hit' in out)
|
|
494
|
+
return { ok: true, number: out.hit.number, id: out.hit.id, url: out.hit.html_url, found: true };
|
|
495
|
+
const json = await bodyOf(out.res);
|
|
496
|
+
if (!out.res.ok)
|
|
497
|
+
return { ok: false, status: out.res.status, error: json?.message ?? 'request failed' };
|
|
498
|
+
return { ok: true, number: json.number, id: json.id, url: json.html_url };
|
|
499
|
+
});
|
|
500
|
+
return [
|
|
501
|
+
{
|
|
502
|
+
name: 'github_create_issue',
|
|
503
|
+
description: 'Open a GitHub issue. repo is owner/name.',
|
|
504
|
+
parameters: { type: 'object', properties: { repo: str, title: str, body: str, labels: { type: 'array', items: { type: 'string' } } }, required: ['repo', 'title'] },
|
|
505
|
+
execute: run('github_create_issue', (a, since) => `${base}/repos/${a.repo}/issues?state=all&sort=created&direction=desc&since=${encodeURIComponent(since)}`, (a) => `${base}/repos/${a.repo}/issues`, (a, mk) => ({ title: String(a.title), body: `${a.body ?? ''}\n\n${mk}`, ...(Array.isArray(a.labels) ? { labels: a.labels } : {}) })),
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
name: 'github_create_comment',
|
|
509
|
+
description: 'Comment on a GitHub issue or pull request. repo is owner/name.',
|
|
510
|
+
parameters: { type: 'object', properties: { repo: str, issue_number: { type: 'integer' }, body: str }, required: ['repo', 'issue_number', 'body'] },
|
|
511
|
+
execute: run('github_create_comment', (a, since) => `${base}/repos/${a.repo}/issues/${Number(a.issue_number)}/comments?since=${encodeURIComponent(since)}`, (a) => `${base}/repos/${a.repo}/issues/${Number(a.issue_number)}/comments`, (a, mk) => ({ body: `${a.body}\n\n${mk}` })),
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
}
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// Generic HTTP POST with an Idempotency-Key header.
|
|
517
|
+
function httpTools(rt, o) {
|
|
518
|
+
const hosts = new Set(o.allowHosts.map((h) => h.toLowerCase()));
|
|
519
|
+
return [
|
|
520
|
+
{
|
|
521
|
+
name: 'http_post',
|
|
522
|
+
description: 'POST a body to an allowed URL. The receiver is sent an Idempotency-Key for this action.',
|
|
523
|
+
parameters: { type: 'object', properties: { url: str, body: { type: 'string', description: 'the request body, such as JSON text' }, content_type: str }, required: ['url', 'body'] },
|
|
524
|
+
execute: (args, ctx) => rt.once(ctx, 'http_post', async () => {
|
|
525
|
+
let u;
|
|
526
|
+
try {
|
|
527
|
+
u = new URL(String(args.url));
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return { ok: false, error: 'not a URL' };
|
|
531
|
+
}
|
|
532
|
+
if (u.protocol !== 'https:' && !(o.allowHttp && u.protocol === 'http:'))
|
|
533
|
+
return { ok: false, error: 'only https URLs are allowed' };
|
|
534
|
+
if (!hosts.has(u.host.toLowerCase()) && !hosts.has(u.hostname.toLowerCase()))
|
|
535
|
+
return { ok: false, error: `host ${u.host} is not allowed` };
|
|
536
|
+
const res = await rt.withRetry('http_post', () => rt.doFetch(u, {
|
|
537
|
+
method: 'POST',
|
|
538
|
+
redirect: 'error',
|
|
539
|
+
headers: { ...(o.headers ?? {}), 'Content-Type': String(args.content_type ?? 'application/json'), 'Idempotency-Key': providerKey(ctx.idempotencyKey) },
|
|
540
|
+
body: String(args.body),
|
|
541
|
+
}));
|
|
542
|
+
const text = await res.text();
|
|
543
|
+
return { ok: res.ok, status: res.status, body: text.slice(0, 2000), replayed: res.headers.get('idempotent-replayed') === 'true' || res.headers.get('idempotency-replayed') === 'true' };
|
|
544
|
+
}),
|
|
545
|
+
},
|
|
546
|
+
];
|
|
547
|
+
}
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
/**
|
|
550
|
+
* Tools for runDurable. Pass only the services you use; each tool is built from your options and your
|
|
551
|
+
* secrets, never from anything the model sends. With Resend and SMTP both set, Resend is `send_email`
|
|
552
|
+
* and SMTP is `send_email_smtp`.
|
|
553
|
+
*/
|
|
554
|
+
export function toolPack(opts) {
|
|
555
|
+
const rt = new Runtime(opts);
|
|
556
|
+
const out = [];
|
|
557
|
+
if (opts.stripe)
|
|
558
|
+
out.push(...stripeTools(rt, opts.stripe));
|
|
559
|
+
if (opts.resend)
|
|
560
|
+
out.push(...resendTools(rt, opts.resend, 'send_email'));
|
|
561
|
+
if (opts.smtp)
|
|
562
|
+
out.push(...smtpTools(rt, opts.smtp, opts.resend ? 'send_email_smtp' : 'send_email'));
|
|
563
|
+
if (opts.slack)
|
|
564
|
+
out.push(...slackTools(rt, opts.slack));
|
|
565
|
+
if (opts.github)
|
|
566
|
+
out.push(...githubTools(rt, opts.github));
|
|
567
|
+
if (opts.http)
|
|
568
|
+
out.push(...httpTools(rt, opts.http));
|
|
569
|
+
return out;
|
|
570
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@argentic-ai/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.14",
|
|
4
|
+
"description": "Argentic client for TypeScript. The engine runs your agent for hours, rebuilding each prompt from a journal (state blocks plus a recent tail of about 40K tokens) instead of an ever-growing transcript. Adapters for the Vercel AI SDK and Mastra.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": { "node": ">=22" },
|
|
8
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
9
|
+
"main": "./dist/argentic.js",
|
|
10
|
+
"bin": { "argentic": "./dist/cli.js" },
|
|
11
|
+
"types": "./dist/argentic.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": { "types": "./dist/argentic.d.ts", "default": "./dist/argentic.js" },
|
|
14
|
+
"./ai-sdk": { "types": "./dist/ai-sdk.d.ts", "default": "./dist/ai-sdk.js" },
|
|
15
|
+
"./mastra": { "types": "./dist/mastra.d.ts", "default": "./dist/mastra.js" },
|
|
16
|
+
"./tools": { "types": "./dist/tools.d.ts", "default": "./dist/tools.js" }
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
20
|
+
"test": "node --experimental-transform-types --disable-warning=ExperimentalWarning --test test/*.test.ts",
|
|
21
|
+
"prepack": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"ai": ">=5 <8"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"ai": { "optional": true }
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"ai": "^6.0.0",
|
|
32
|
+
"typescript": "^5.7.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Argentic-AI/argentic-plugins.git",
|
|
37
|
+
"directory": "adapters/typescript"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://getargentic.com/docs",
|
|
40
|
+
"keywords": ["agents", "long-running", "context", "journal", "ai-sdk", "mastra", "argentic"],
|
|
41
|
+
"publishConfig": { "access": "public", "provenance": true }
|
|
42
|
+
}
|