@msg91comm/m91 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/dist/src/client.d.ts +165 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +221 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/exceptions.d.ts +114 -0
- package/dist/src/exceptions.d.ts.map +1 -0
- package/dist/src/exceptions.js +199 -0
- package/dist/src/exceptions.js.map +1 -0
- package/dist/src/http.d.ts +71 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +150 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +49 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +45 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/integrations/index.d.ts +22 -0
- package/dist/src/integrations/index.d.ts.map +1 -0
- package/dist/src/integrations/index.js +21 -0
- package/dist/src/integrations/index.js.map +1 -0
- package/dist/src/integrations/langchain.d.ts +43 -0
- package/dist/src/integrations/langchain.d.ts.map +1 -0
- package/dist/src/integrations/langchain.js +75 -0
- package/dist/src/integrations/langchain.js.map +1 -0
- package/dist/src/integrations/langgraph.d.ts +79 -0
- package/dist/src/integrations/langgraph.d.ts.map +1 -0
- package/dist/src/integrations/langgraph.js +114 -0
- package/dist/src/integrations/langgraph.js.map +1 -0
- package/dist/src/integrations/specs.d.ts +131 -0
- package/dist/src/integrations/specs.d.ts.map +1 -0
- package/dist/src/integrations/specs.js +156 -0
- package/dist/src/integrations/specs.js.map +1 -0
- package/dist/src/integrations/vercel-ai.d.ts +36 -0
- package/dist/src/integrations/vercel-ai.d.ts.map +1 -0
- package/dist/src/integrations/vercel-ai.js +67 -0
- package/dist/src/integrations/vercel-ai.js.map +1 -0
- package/dist/src/models.d.ts +274 -0
- package/dist/src/models.d.ts.map +1 -0
- package/dist/src/models.js +383 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/platform.d.ts +176 -0
- package/dist/src/platform.d.ts.map +1 -0
- package/dist/src/platform.js +286 -0
- package/dist/src/platform.js.map +1 -0
- package/dist/src/url-builder.d.ts +65 -0
- package/dist/src/url-builder.d.ts.map +1 -0
- package/dist/src/url-builder.js +167 -0
- package/dist/src/url-builder.js.map +1 -0
- package/examples/approval-gate.ts +35 -0
- package/examples/basic-alert.ts +53 -0
- package/examples/langchain-agent.ts +65 -0
- package/examples/langgraph-hitl.ts +70 -0
- package/examples/vercel-ai-agent.ts +34 -0
- package/examples/webhook-urls.ts +44 -0
- package/package.json +83 -4
- package/src/client.ts +324 -0
- package/src/exceptions.ts +229 -0
- package/src/http.ts +174 -0
- package/src/index.ts +91 -0
- package/src/integrations/index.ts +23 -0
- package/src/integrations/langchain.ts +104 -0
- package/src/integrations/langgraph.ts +142 -0
- package/src/integrations/specs.ts +224 -0
- package/src/integrations/vercel-ai.ts +99 -0
- package/src/models.ts +660 -0
- package/src/platform.ts +343 -0
- package/src/url-builder.ts +225 -0
package/src/http.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP on native `fetch`, with the retry policy applied. Zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* The rules, which are the interesting part:
|
|
5
|
+
*
|
|
6
|
+
* - **429 is retried exactly once**, after exactly the delay the server asked
|
|
7
|
+
* for. The send limit is 60/minute per link and every 429 carries
|
|
8
|
+
* `Retry-After: 60`. Retrying a second time would be arguing with it.
|
|
9
|
+
* - **5xx is retried with backoff.** Not the caller's fault, and the alert has
|
|
10
|
+
* not been raised, so repeating it cannot double-page anybody.
|
|
11
|
+
* - **4xx is never retried.** Not transient; the next identical request fails
|
|
12
|
+
* identically.
|
|
13
|
+
* - **A network failure is never retried.** A send that timed out may or may not
|
|
14
|
+
* have raised the alert — the response is what was lost, not necessarily the
|
|
15
|
+
* request. Blind retries are how one condition wakes a team four times.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { M91NetworkError, retryAfter, unwrap } from './exceptions.js';
|
|
19
|
+
|
|
20
|
+
export const USER_AGENT = 'm91-node/1.0.0';
|
|
21
|
+
|
|
22
|
+
/** Delays before the 2nd, 3rd and 4th attempt at a 5xx. Four requests total. */
|
|
23
|
+
export const SERVER_BACKOFF = [1000, 2000, 4000] as const;
|
|
24
|
+
|
|
25
|
+
export const RETRYABLE_STATUSES = new Set([500, 502, 503, 504]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A 429 whose `Retry-After` is longer than this is thrown rather than slept
|
|
29
|
+
* through. The server sends 60; anything far past that is a caller sitting
|
|
30
|
+
* blocked in an alerting path, which is worse than an error it can see.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_RETRY_AFTER_MS = 120_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Milliseconds to wait before trying again, or `null` to stop and throw.
|
|
36
|
+
* `attempt` is 1-based and counts requests already made.
|
|
37
|
+
*/
|
|
38
|
+
export function retryDelay(status: number, attempt: number, retryAfterSeconds?: number): number | null {
|
|
39
|
+
if (status === 429) {
|
|
40
|
+
// Once, and only on the first 429. A second means the window has not
|
|
41
|
+
// actually cleared and the caller needs to hear about it.
|
|
42
|
+
if (attempt >= 2) return null;
|
|
43
|
+
const wait = (retryAfterSeconds ?? 60) * 1000;
|
|
44
|
+
return wait > MAX_RETRY_AFTER_MS ? null : Math.max(wait, 0);
|
|
45
|
+
}
|
|
46
|
+
if (RETRYABLE_STATUSES.has(status)) {
|
|
47
|
+
return attempt > SERVER_BACKOFF.length ? null : SERVER_BACKOFF[attempt - 1];
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Matches a send link anywhere — in a bare URL, or buried in a sentence — and
|
|
54
|
+
* takes any query string attached to it. Bounded on whitespace so it stops at
|
|
55
|
+
* the end of the URL rather than eating the rest of the message.
|
|
56
|
+
*/
|
|
57
|
+
const SEND_URL_RE = /(\/s\/)[^\s/?#]+(\?[^\s]*)?/g;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Replace a send link's token with `***`, wherever it appears.
|
|
61
|
+
*
|
|
62
|
+
* **The token is the credential and it lives in the path**, so a URL is as
|
|
63
|
+
* sensitive as a password and must never reach a log, an error message or a
|
|
64
|
+
* stack trace intact. The server holds itself to the same rule — its error
|
|
65
|
+
* handler reports the route *pattern*, never `req.path`.
|
|
66
|
+
*
|
|
67
|
+
* This takes free text, not only a URL, and that is the point. Redacting only
|
|
68
|
+
* the URL the SDK interpolates is not enough: a fetch failure can carry the URL
|
|
69
|
+
* in its own message, so a carefully-redacted sentence would arrive with the
|
|
70
|
+
* raw token attached to it by the runtime.
|
|
71
|
+
*
|
|
72
|
+
* Query strings go too: a GET-form send carries the alert title, and titles
|
|
73
|
+
* routinely name customers and hosts.
|
|
74
|
+
*/
|
|
75
|
+
export function redact(text: string): string {
|
|
76
|
+
if (typeof text !== 'string') return text;
|
|
77
|
+
return text.replace(SEND_URL_RE, '$1***');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RequestOptions {
|
|
81
|
+
method: string;
|
|
82
|
+
url: string;
|
|
83
|
+
body?: unknown;
|
|
84
|
+
query?: Record<string, string>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TransportOptions {
|
|
88
|
+
/** Whole-request budget in milliseconds. Default 15s (5s connect + 10s read). */
|
|
89
|
+
timeoutMs?: number;
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
/** Swappable so tests need no network and no mocking library. */
|
|
92
|
+
fetchImpl?: typeof fetch;
|
|
93
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
97
|
+
|
|
98
|
+
export class Transport {
|
|
99
|
+
private readonly timeoutMs: number;
|
|
100
|
+
private readonly headers: Record<string, string>;
|
|
101
|
+
private readonly fetchImpl: typeof fetch;
|
|
102
|
+
private readonly sleepImpl: (ms: number) => Promise<void>;
|
|
103
|
+
|
|
104
|
+
constructor(options: TransportOptions = {}) {
|
|
105
|
+
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
106
|
+
this.headers = {
|
|
107
|
+
// A GET-form send answers with a rendered HTML page when the caller looks
|
|
108
|
+
// like a browser. That page exists so a person tapping a bookmark sees
|
|
109
|
+
// something other than a wall of JSON — but an SDK is not a person, and
|
|
110
|
+
// has to opt out of it explicitly.
|
|
111
|
+
accept: 'application/json',
|
|
112
|
+
'user-agent': USER_AGENT,
|
|
113
|
+
...options.headers,
|
|
114
|
+
};
|
|
115
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
this.sleepImpl = options.sleepImpl ?? defaultSleep;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async request({ method, url, body, query }: RequestOptions): Promise<unknown> {
|
|
120
|
+
const target = query ? `${url}?${new URLSearchParams(query).toString()}` : url;
|
|
121
|
+
let attempt = 0;
|
|
122
|
+
|
|
123
|
+
for (;;) {
|
|
124
|
+
attempt += 1;
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
127
|
+
|
|
128
|
+
let status: number;
|
|
129
|
+
let headers: Headers;
|
|
130
|
+
let parsed: unknown;
|
|
131
|
+
try {
|
|
132
|
+
const response = await this.fetchImpl(target, {
|
|
133
|
+
method,
|
|
134
|
+
headers: body === undefined
|
|
135
|
+
? this.headers
|
|
136
|
+
: { ...this.headers, 'content-type': 'application/json' },
|
|
137
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
138
|
+
signal: controller.signal,
|
|
139
|
+
});
|
|
140
|
+
status = response.status;
|
|
141
|
+
headers = response.headers;
|
|
142
|
+
parsed = await readBody(response);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Redacted AFTER composing, not before: a fetch failure can name the
|
|
145
|
+
// URL in its own message, so masking only our half still ships the token.
|
|
146
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
147
|
+
throw new M91NetworkError(redact(`Could not reach M91 at ${target}: ${reason}`));
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const delay = retryDelay(status, attempt, retryAfter(headers));
|
|
153
|
+
if (delay === null) return unwrap(status, parsed, headers);
|
|
154
|
+
await this.sleepImpl(delay);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The parsed envelope, or something `unwrap` can still turn into an error.
|
|
161
|
+
*
|
|
162
|
+
* A non-JSON body here means a proxy, a load balancer or an HTML error page
|
|
163
|
+
* answered instead of M91. Wrapping the text in an envelope-shaped object keeps
|
|
164
|
+
* all the error construction in one place rather than adding a second path.
|
|
165
|
+
*/
|
|
166
|
+
async function readBody(response: Response): Promise<unknown> {
|
|
167
|
+
const text = await response.text();
|
|
168
|
+
if (!text) return { error: { message: `HTTP ${response.status} with an empty body` } };
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(text);
|
|
171
|
+
} catch {
|
|
172
|
+
return { error: { message: text.slice(0, 300) } };
|
|
173
|
+
}
|
|
174
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M91 — wake a human when software cannot carry on alone.
|
|
3
|
+
*
|
|
4
|
+
* An alert rings every phone in a channel at full volume, through silent mode
|
|
5
|
+
* and Do Not Disturb, and keeps ringing until somebody answers. HIGH and
|
|
6
|
+
* CRITICAL place a voice call if 45 seconds pass with no answer. Whoever takes
|
|
7
|
+
* it on is recorded, and it clears for everyone else.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { M91Client } from '@msg91comm/m91';
|
|
11
|
+
*
|
|
12
|
+
* const m91 = new M91Client(process.env.M91_SEND_LINK!);
|
|
13
|
+
* await m91.verify(); // raises nothing; names the channel
|
|
14
|
+
* await m91.send('Payments API is down', { severity: 'CRITICAL', customId: 'payments-down' });
|
|
15
|
+
* await m91.close({ customId: 'payments-down' });
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* And the reason an agent wants it — a question that blocks until a person
|
|
19
|
+
* answers on their phone:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* if (await m91.requestApproval('Refund 48,000 on ORD-40122?', { customId: 'refund-ORD-40122' })) {
|
|
23
|
+
* await processRefund();
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Everything here is a convenience over one URL. The send link works from a
|
|
28
|
+
* browser bar, a cron `curl` or an `<img>` tag with no client at all, and
|
|
29
|
+
* `M91URLBuilder` is for exactly those.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export { M91Client } from './client.js';
|
|
33
|
+
export type { ApprovalOptions, ClientOptions, SendOptions, WaitOptions } from './client.js';
|
|
34
|
+
|
|
35
|
+
export { M91Platform, DEFAULT_BASE_URL } from './platform.js';
|
|
36
|
+
export type { PlatformOptions } from './platform.js';
|
|
37
|
+
|
|
38
|
+
export { M91URLBuilder, normalizeSendLink } from './url-builder.js';
|
|
39
|
+
export type { BuildManySpec, BuildUrlOptions } from './url-builder.js';
|
|
40
|
+
|
|
41
|
+
export { redact } from './http.js';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
M91Error,
|
|
45
|
+
M91ValidationError,
|
|
46
|
+
M91AuthError,
|
|
47
|
+
M91ForbiddenError,
|
|
48
|
+
M91NotFoundError,
|
|
49
|
+
M91ChannelEmptyError,
|
|
50
|
+
M91RateLimitError,
|
|
51
|
+
M91ServerError,
|
|
52
|
+
M91NetworkError,
|
|
53
|
+
M91DeliveryError,
|
|
54
|
+
M91TimeoutError,
|
|
55
|
+
} from './exceptions.js';
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
SEVERITIES,
|
|
59
|
+
EFFECTS,
|
|
60
|
+
ESCALATING_SEVERITIES,
|
|
61
|
+
isApproved,
|
|
62
|
+
validatePhone,
|
|
63
|
+
buildAlertPayload,
|
|
64
|
+
} from './models.js';
|
|
65
|
+
|
|
66
|
+
export type {
|
|
67
|
+
AlertDetail,
|
|
68
|
+
AlertResponse,
|
|
69
|
+
AlertStatus,
|
|
70
|
+
ApiKeyInfo,
|
|
71
|
+
ChannelInfo,
|
|
72
|
+
Attachment,
|
|
73
|
+
Decision,
|
|
74
|
+
Response,
|
|
75
|
+
Effect,
|
|
76
|
+
InviteResult,
|
|
77
|
+
ProvisionedChannel,
|
|
78
|
+
Recipient,
|
|
79
|
+
RecipientState,
|
|
80
|
+
ResponseOption,
|
|
81
|
+
Severity,
|
|
82
|
+
} from './models.js';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Kept in step with package.json by `tests/version.test.ts`, not by hand.
|
|
86
|
+
*
|
|
87
|
+
* ESM has no portable way to read package.json at runtime, so this number is
|
|
88
|
+
* written twice — and it drifted, reporting 1.0.0 while the package shipped as
|
|
89
|
+
* 1.1.0. A test is what makes two copies safe.
|
|
90
|
+
*/
|
|
91
|
+
export const VERSION = '1.1.0';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework adapters, re-exported.
|
|
3
|
+
*
|
|
4
|
+
* Each one imports its framework lazily, so `npm install @msg91comm/m91` pulls in none of
|
|
5
|
+
* them and using one you have not installed says which package to install
|
|
6
|
+
* rather than failing with a bare module-not-found from three frames down.
|
|
7
|
+
*
|
|
8
|
+
* Importing a specific adapter directly is usually better —
|
|
9
|
+
* `@msg91comm/m91/integrations/langchain` — because it pulls in only what you use.
|
|
10
|
+
*
|
|
11
|
+
* `langchain.ts` and `vercel-ai.ts` both export `createM91ApprovalTool`, so
|
|
12
|
+
* they are re-exported under namespaces rather than flattened: two functions of
|
|
13
|
+
* the same name doing the same job for different frameworks is exactly the pair
|
|
14
|
+
* that would silently resolve to the wrong one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export * as langchain from './langchain.js';
|
|
18
|
+
export * as langgraph from './langgraph.js';
|
|
19
|
+
export * as vercelAi from './vercel-ai.js';
|
|
20
|
+
|
|
21
|
+
export { M91InterruptNotifier, createM91ApprovalNode } from './langgraph.js';
|
|
22
|
+
export { APPROVED, REJECTED, alertResult, errorResult } from './specs.js';
|
|
23
|
+
export type { AlertArgs, ApprovalArgs } from './specs.js';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangChain tools.
|
|
3
|
+
*
|
|
4
|
+
* Two of them, and the split matters more than it looks:
|
|
5
|
+
*
|
|
6
|
+
* - the approval tool **blocks**. The agent stops until a human answers on
|
|
7
|
+
* their phone. Give it to an agent that can do something irreversible.
|
|
8
|
+
* - the alert tool does not. It wakes somebody and carries on.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { M91Client } from '@msg91comm/m91';
|
|
12
|
+
* import { createM91ApprovalTool, createM91AlertTool } from '@msg91comm/m91/integrations/langchain';
|
|
13
|
+
*
|
|
14
|
+
* const client = new M91Client(process.env.M91_SEND_LINK!);
|
|
15
|
+
* const tools = [
|
|
16
|
+
* await createM91ApprovalTool(client),
|
|
17
|
+
* await createM91AlertTool(client),
|
|
18
|
+
* ...myTools,
|
|
19
|
+
* ];
|
|
20
|
+
* const agent = createReactAgent({ llm, tools });
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* The factories are **async** because `@langchain/core` is an optional peer
|
|
24
|
+
* dependency, imported only when one of these is called. That keeps `npm
|
|
25
|
+
* install @msg91comm/m91` at zero dependencies, which matters for a package whose job is
|
|
26
|
+
* to work when everything else is on fire.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { M91Client } from '../client.js';
|
|
30
|
+
import {
|
|
31
|
+
ALERT_DESCRIPTION,
|
|
32
|
+
ALERT_SCHEMA,
|
|
33
|
+
ALERT_TOOL_NAME,
|
|
34
|
+
APPROVAL_DESCRIPTION,
|
|
35
|
+
APPROVAL_SCHEMA,
|
|
36
|
+
APPROVAL_TOOL_NAME,
|
|
37
|
+
type AlertArgs,
|
|
38
|
+
type AlertDefaults,
|
|
39
|
+
type ApprovalArgs,
|
|
40
|
+
type ApprovalDefaults,
|
|
41
|
+
runAlert,
|
|
42
|
+
runApproval,
|
|
43
|
+
} from './specs.js';
|
|
44
|
+
|
|
45
|
+
async function langchainTool(): Promise<(fn: unknown, config: unknown) => unknown> {
|
|
46
|
+
try {
|
|
47
|
+
// The specifier is a variable so TypeScript does not try to resolve an
|
|
48
|
+
// optional peer dependency at build time. Compiling this package must not
|
|
49
|
+
// require every framework it can adapt to.
|
|
50
|
+
const specifier = '@langchain/core/tools';
|
|
51
|
+
const mod = (await import(specifier)) as { tool: (fn: unknown, config: unknown) => unknown };
|
|
52
|
+
return mod.tool;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'The LangChain integration needs @langchain/core. Install it with: ' +
|
|
56
|
+
'npm install @langchain/core',
|
|
57
|
+
{ cause: error },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The blocking approval gate, as a LangChain tool named `m91_request_approval`.
|
|
64
|
+
* Returns `"approved"`, `"rejected"` or `"error: …"` — and nothing but a real
|
|
65
|
+
* human approval returns `"approved"`.
|
|
66
|
+
*/
|
|
67
|
+
export async function createM91ApprovalTool(
|
|
68
|
+
client: M91Client,
|
|
69
|
+
defaults: ApprovalDefaults = {},
|
|
70
|
+
): Promise<unknown> {
|
|
71
|
+
const tool = await langchainTool();
|
|
72
|
+
return tool((args: ApprovalArgs) => runApproval(client, args, defaults), {
|
|
73
|
+
name: APPROVAL_TOOL_NAME,
|
|
74
|
+
description: APPROVAL_DESCRIPTION,
|
|
75
|
+
schema: APPROVAL_SCHEMA,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The fire-and-forget alert, as a LangChain tool named `m91_send_alert`.
|
|
81
|
+
* Returns `"sent:delivered=N"` or `"error: …"`.
|
|
82
|
+
*/
|
|
83
|
+
export async function createM91AlertTool(
|
|
84
|
+
client: M91Client,
|
|
85
|
+
defaults: AlertDefaults = {},
|
|
86
|
+
): Promise<unknown> {
|
|
87
|
+
const tool = await langchainTool();
|
|
88
|
+
return tool((args: AlertArgs) => runAlert(client, args, defaults), {
|
|
89
|
+
name: ALERT_TOOL_NAME,
|
|
90
|
+
description: ALERT_DESCRIPTION,
|
|
91
|
+
schema: ALERT_SCHEMA,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Both, in the order an agent's `tools` array usually wants them. */
|
|
96
|
+
export async function createM91Tools(
|
|
97
|
+
client: M91Client,
|
|
98
|
+
defaults: ApprovalDefaults & AlertDefaults = {},
|
|
99
|
+
): Promise<unknown[]> {
|
|
100
|
+
return [
|
|
101
|
+
await createM91ApprovalTool(client, defaults),
|
|
102
|
+
await createM91AlertTool(client, defaults),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LangGraph helpers for the `interrupt()` pattern.
|
|
3
|
+
*
|
|
4
|
+
* LangGraph already knows how to stop a graph and resume it. What it has no
|
|
5
|
+
* opinion about is **how the human finds out it stopped** — and the default
|
|
6
|
+
* answer is that somebody notices a paused run in a web UI. At 2am, for a graph
|
|
7
|
+
* that paused because it wants to move money, nobody does. M91 fills exactly
|
|
8
|
+
* that gap: the graph pauses, a phone rings until somebody answers, and the
|
|
9
|
+
* answer resumes the graph.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here imports `@langchain/langgraph`. These are plain callables you use
|
|
12
|
+
* *around* your graph, so they work the same with `interrupt()`, with
|
|
13
|
+
* `new Command({ resume })`, or with a node you wrote by hand.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { M91Client } from '../client.js';
|
|
17
|
+
import type { Severity } from '../models.js';
|
|
18
|
+
import { APPROVED, REJECTED } from './specs.js';
|
|
19
|
+
|
|
20
|
+
export interface NotifyOptions {
|
|
21
|
+
title: string;
|
|
22
|
+
customId: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
severity?: Severity;
|
|
25
|
+
approveLabel?: string;
|
|
26
|
+
rejectLabel?: string;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
pollIntervalMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type NotifyDefaults = Omit<NotifyOptions, 'title' | 'customId'>;
|
|
32
|
+
|
|
33
|
+
export type Verdict = typeof APPROVED | typeof REJECTED;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wake a human about an interrupted graph and return what they said.
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* const notifier = new M91InterruptNotifier(client);
|
|
40
|
+
*
|
|
41
|
+
* const result = await graph.invoke(state, config);
|
|
42
|
+
* if (result.__interrupt__) {
|
|
43
|
+
* const data = result.__interrupt__[0].value;
|
|
44
|
+
* const decision = await notifier.notify({
|
|
45
|
+
* title: data.question,
|
|
46
|
+
* customId: data.customId,
|
|
47
|
+
* timeoutMs: 600_000,
|
|
48
|
+
* });
|
|
49
|
+
* await graph.invoke(new Command({ resume: decision }), config);
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* Resolves `"approved"` or `"rejected"` and **never anything else** — a failure
|
|
54
|
+
* to reach anybody comes back as `"rejected"`, because a graph that resumes
|
|
55
|
+
* with an ambiguous value resumes with whatever the node's `if` happens to do
|
|
56
|
+
* with it.
|
|
57
|
+
*/
|
|
58
|
+
export class M91InterruptNotifier {
|
|
59
|
+
private readonly client: M91Client;
|
|
60
|
+
private readonly defaults: NotifyDefaults;
|
|
61
|
+
|
|
62
|
+
constructor(client: M91Client, defaults: NotifyDefaults = {}) {
|
|
63
|
+
this.client = client;
|
|
64
|
+
this.defaults = defaults;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async notify(options: NotifyOptions): Promise<Verdict> {
|
|
68
|
+
const { title, customId, ...rest } = options;
|
|
69
|
+
const merged = { ...this.defaults, ...strip(rest) };
|
|
70
|
+
try {
|
|
71
|
+
const approved = await this.client.requestApproval(title, {
|
|
72
|
+
customId,
|
|
73
|
+
severity: 'HIGH',
|
|
74
|
+
timeoutMs: 300_000,
|
|
75
|
+
...merged,
|
|
76
|
+
});
|
|
77
|
+
return approved ? APPROVED : REJECTED;
|
|
78
|
+
} catch {
|
|
79
|
+
// Nobody could be reached, so nobody approved. Resuming a graph with
|
|
80
|
+
// "approved" because the pager was down is the one outcome worth ruling
|
|
81
|
+
// out by construction.
|
|
82
|
+
return REJECTED;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a graph node that asks a human and writes the answer into state.
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* builder.addNode('humanApproval', createM91ApprovalNode(client, {
|
|
92
|
+
* approveLabel: 'Deploy Now',
|
|
93
|
+
* rejectLabel: 'Hold Release',
|
|
94
|
+
* timeoutMs: 600_000,
|
|
95
|
+
* }));
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* The node reads `title` (or `question`) and `customId` from state and returns
|
|
99
|
+
* `{ decision, approved }` for the next edge to branch on. Anything passed here
|
|
100
|
+
* is a default the state can override per run.
|
|
101
|
+
*/
|
|
102
|
+
export function createM91ApprovalNode(
|
|
103
|
+
client: M91Client,
|
|
104
|
+
defaults: NotifyDefaults = {},
|
|
105
|
+
): (state: unknown) => Promise<{ decision: Verdict; approved: boolean; error?: string }> {
|
|
106
|
+
const notifier = new M91InterruptNotifier(client, defaults);
|
|
107
|
+
|
|
108
|
+
return async (state: unknown) => {
|
|
109
|
+
const data = (state && typeof state === 'object' ? state : {}) as Record<string, unknown>;
|
|
110
|
+
const title = data.title ?? data.question;
|
|
111
|
+
const customId = data.customId ?? data.custom_id;
|
|
112
|
+
|
|
113
|
+
if (!title || !customId) {
|
|
114
|
+
// Said loudly rather than defaulted. A node that invents a customId is a
|
|
115
|
+
// node whose alerts never deduplicate and never close.
|
|
116
|
+
return {
|
|
117
|
+
decision: REJECTED,
|
|
118
|
+
approved: false,
|
|
119
|
+
error:
|
|
120
|
+
"error: the approval node needs 'title' (or 'question') and 'customId' in state",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const decision = await notifier.notify({
|
|
125
|
+
title: String(title),
|
|
126
|
+
customId: String(customId),
|
|
127
|
+
...strip({
|
|
128
|
+
description: data.description as string | undefined,
|
|
129
|
+
severity: data.severity as Severity | undefined,
|
|
130
|
+
approveLabel: data.approveLabel as string | undefined,
|
|
131
|
+
rejectLabel: data.rejectLabel as string | undefined,
|
|
132
|
+
timeoutMs: data.timeoutMs as number | undefined,
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
return { decision, approved: decision === APPROVED };
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Drop undefined keys so a spread cannot blank out a default with `undefined`. */
|
|
140
|
+
function strip<T extends Record<string, unknown>>(value: T): Partial<T> {
|
|
141
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as Partial<T>;
|
|
142
|
+
}
|