@letsping/sdk 0.1.6 → 0.2.1
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/README.md +195 -17
- package/examples/langgraph-demo.ts +80 -0
- package/package.json +1 -1
- package/src/index.d.ts +58 -1
- package/src/index.ts +240 -7
- package/src/integrations/langgraph.ts +105 -7
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ LetsPing is a behavioral firewall and Human-in-the-Loop (HITL) infrastructure la
|
|
|
8
8
|
- **The Behavioral Shield:** Silently profiles your agent's execution paths via Markov Chains. Automatically intercepts 0-probability reasoning anomalies (hallucinations/prompt injections).
|
|
9
9
|
- **Cryo-Sleep State Parking:** Pauses execution and securely uploads massive agent states directly to storage using Signed URLs, entirely bypassing serverless timeouts and webhook payload limits.
|
|
10
10
|
- **Smart-Accept Drift Adaptation:** Approval decisions mathematically alter the baseline. Old unused reasoning paths decay automatically via Exponential Moving Average (EMA).
|
|
11
|
+
- **Agent Identity & Escrow Helpers:** Optional HMAC-based helpers (`signAgentCall`, `verifyEscrow`, `chainHandoff`) for cryptographically linking agent calls and handoffs to LetsPing requests.
|
|
11
12
|
|
|
12
13
|
## Requirements
|
|
13
14
|
- Node.js 18+
|
|
@@ -22,6 +23,27 @@ npm install @letsping/sdk
|
|
|
22
23
|
|
|
23
24
|
## Usage
|
|
24
25
|
|
|
26
|
+
### Minimal drop-in example
|
|
27
|
+
|
|
28
|
+
The fastest way to see your first approval in the dashboard:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { LetsPing } from "@letsping/sdk";
|
|
32
|
+
|
|
33
|
+
const apiKey = process.env.LETSPING_API_KEY;
|
|
34
|
+
if (!apiKey) throw new Error("Missing LETSPING_API_KEY env var.");
|
|
35
|
+
|
|
36
|
+
const lp = new LetsPing(apiKey);
|
|
37
|
+
|
|
38
|
+
const decision = await lp.ask({
|
|
39
|
+
service: "billing-agent",
|
|
40
|
+
action: "refund_user",
|
|
41
|
+
payload: { user_id: "u_123", amount: 100 },
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Every example in this README follows the same pattern: **either pass the key explicitly or rely on `LETSPING_API_KEY` via env**.
|
|
46
|
+
|
|
25
47
|
### Blocking Request (`ask`)
|
|
26
48
|
|
|
27
49
|
Execution suspends until the request is approved, rejected, or times out.
|
|
@@ -34,7 +56,7 @@ const lp = new LetsPing(process.env.LETSPING_API_KEY!);
|
|
|
34
56
|
async function processRefund(userId: string, amount: number) {
|
|
35
57
|
try {
|
|
36
58
|
const decision = await lp.ask({
|
|
37
|
-
service: "billing-
|
|
59
|
+
service: "billing-agent",
|
|
38
60
|
action: "refund_user",
|
|
39
61
|
priority: "high",
|
|
40
62
|
payload: { userId, amount },
|
|
@@ -92,36 +114,115 @@ const { id } = await lp.defer({
|
|
|
92
114
|
console.log(`Approval request queued → ${id}`);
|
|
93
115
|
```
|
|
94
116
|
|
|
95
|
-
### Webhook Rehydration (
|
|
96
|
-
|
|
117
|
+
### Webhook Rehydration (Next.js Example)
|
|
118
|
+
|
|
119
|
+
When you pass `state_snapshot` to `ask` / `defer`, the SDK:
|
|
120
|
+
|
|
121
|
+
- Encrypts the snapshot with either `LETSPING_ENCRYPTION_KEY` or a one‑time DEK.
|
|
122
|
+
- Uploads it directly to your storage bucket using a signed URL.
|
|
123
|
+
- Includes a `state_download_url` (and DEK) in subsequent webhooks.
|
|
124
|
+
|
|
125
|
+
You can use the built‑in `webhookHandler` to validate and hydrate webhooks in a Next.js App Router route:
|
|
97
126
|
|
|
98
127
|
```typescript
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
128
|
+
// Example Next.js App Router route
|
|
129
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
130
|
+
import { LetsPing } from "@letsping/sdk";
|
|
131
|
+
|
|
132
|
+
const lp = new LetsPing();
|
|
133
|
+
const WEBHOOK_SECRET = process.env.LETSPING_WEBHOOK_SECRET!;
|
|
134
|
+
|
|
135
|
+
export async function POST(req: NextRequest) {
|
|
136
|
+
const rawBody = await req.text();
|
|
137
|
+
const signature = req.headers.get("x-letsping-signature") || "";
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const { id, event, data, state_snapshot } = await lp.webhookHandler(
|
|
141
|
+
rawBody,
|
|
142
|
+
signature,
|
|
143
|
+
WEBHOOK_SECRET
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// At this point:
|
|
147
|
+
// - `data` contains the decision payload (status, payload, patched_payload, metadata, etc.)
|
|
148
|
+
// - `state_snapshot` contains your decrypted agent state, if Cryo-Sleep was used.
|
|
149
|
+
|
|
150
|
+
await handleDecision({ id, event, data, state_snapshot });
|
|
151
|
+
|
|
152
|
+
return NextResponse.json({ ok: true });
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
console.error("LetsPing webhook error:", err);
|
|
155
|
+
return NextResponse.json({ error: "invalid webhook" }, { status: 400 });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function handleDecision(args: {
|
|
160
|
+
id: string;
|
|
161
|
+
event: string;
|
|
162
|
+
data: any;
|
|
163
|
+
state_snapshot?: Record<string, any>;
|
|
164
|
+
}) {
|
|
165
|
+
// Example: resume a workflow run or LangGraph thread using `state_snapshot`
|
|
107
166
|
}
|
|
108
167
|
```
|
|
109
168
|
|
|
169
|
+
This pattern works similarly for Express/Fastify — call `lp.webhookHandler(rawBody, signature, secret)`, then resume your framework using the provided `state_snapshot`.
|
|
170
|
+
|
|
110
171
|
### LangGraph Integration (Persisted State)
|
|
111
172
|
|
|
112
|
-
LetsPing provides a `LetsPingCheckpointer` for LangGraph JS/TS
|
|
173
|
+
LetsPing provides a `LetsPingCheckpointer` for LangGraph JS/TS under `@letsping/sdk/integrations/langgraph`.
|
|
174
|
+
In v0.2 this checkpointer persists checkpoints **remotely** via the LetsPing control plane — encrypted alongside your existing Cryo‑Sleep state in Supabase Storage. Threads can survive process restarts without you wiring your own database.
|
|
113
175
|
|
|
114
176
|
```typescript
|
|
115
177
|
import { StateGraph } from "@langchain/langgraph";
|
|
116
178
|
import { LetsPing } from "@letsping/sdk";
|
|
179
|
+
import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
|
|
180
|
+
|
|
181
|
+
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
|
|
182
|
+
const checkpointer = new LetsPingCheckpointer(lp);
|
|
183
|
+
|
|
184
|
+
const builder = new StateGraph<any /* your state type */>({});
|
|
185
|
+
const graph = builder.compile({ checkpointer });
|
|
186
|
+
```
|
|
117
187
|
|
|
118
|
-
|
|
188
|
+
#### Auto‑resuming a thread after approval (webhook + checkpointer)
|
|
189
|
+
|
|
190
|
+
Because checkpoints are stored remotely, you can resume a LangGraph thread from any worker once a human clicks Approve. A minimal Next.js webhook + auto‑resume flow looks like:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// Example Next.js App Router route for LangGraph auto-resume
|
|
194
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
195
|
+
import { LetsPing } from "@letsping/sdk";
|
|
196
|
+
import { StateGraph } from "@langchain/langgraph";
|
|
119
197
|
import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
|
|
198
|
+
import { graphBuilder } from "@/lib/langgraph"; // your app's graph definition
|
|
120
199
|
|
|
121
200
|
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
|
|
122
201
|
const checkpointer = new LetsPingCheckpointer(lp);
|
|
202
|
+
const graph = graphBuilder.compile({ checkpointer });
|
|
203
|
+
|
|
204
|
+
export async function POST(req: NextRequest) {
|
|
205
|
+
const raw = await req.text();
|
|
206
|
+
const sig = req.headers.get("x-letsping-signature") || "";
|
|
207
|
+
|
|
208
|
+
const event = await lp.webhookHandler(raw, sig, process.env.LETSPING_WEBHOOK_SECRET!);
|
|
209
|
+
const { data, state_snapshot } = event;
|
|
210
|
+
|
|
211
|
+
// You decide how to encode the thread id into your state snapshot.
|
|
212
|
+
const threadId = state_snapshot?.thread_id as string | undefined;
|
|
213
|
+
if (!threadId) return NextResponse.json({ ok: false, error: "missing_thread_id" }, { status: 400 });
|
|
214
|
+
|
|
215
|
+
// Resume the graph from the latest remote checkpoint.
|
|
216
|
+
await graph.invoke(state_snapshot.input, {
|
|
217
|
+
configurable: { thread_id: threadId },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return NextResponse.json({ ok: true });
|
|
221
|
+
}
|
|
123
222
|
```
|
|
124
223
|
|
|
224
|
+
In your agent runner, you simply include `thread_id` and `state_snapshot` when you first call LetsPing from inside a LangGraph node. The checkpointer and webhook then keep the thread resumable across restarts. If the human edited the payload in the dashboard, `data.patched_payload` (or `data.payload`) is available in the webhook payload — use your framework’s normal state-update or channel overwrite semantics to inject the approved payload into the resumed graph so the run sees the correct values.
|
|
225
|
+
|
|
125
226
|
## API Reference
|
|
126
227
|
|
|
127
228
|
### `new LetsPing(apiKey, options?)`
|
|
@@ -140,7 +241,7 @@ Blocks until resolved (approve / reject / timeout).
|
|
|
140
241
|
| `payload` | `Record<string, any>` | Context passed to human operator (and returned in Decision) |
|
|
141
242
|
| `priority` | `"low" \| "medium" \| "high" \| "critical"` | Routing priority in dashboard |
|
|
142
243
|
| `schema` | `object` | JSON Schema (draft 07) — generates editable form in dashboard |
|
|
143
|
-
| `timeoutMs` | `number` | Max wait time (default: 86_400_000 ms = 24 hours)
|
|
244
|
+
| `timeoutMs` | `number` | Max wait time in **milliseconds** (default: 86_400_000 ms = 24 hours) |
|
|
144
245
|
|
|
145
246
|
### `lp.defer(options): Promise<{ id: string }>`
|
|
146
247
|
|
|
@@ -150,12 +251,14 @@ Fire-and-forget: queues request and returns request ID immediately. Same options
|
|
|
150
251
|
|
|
151
252
|
```typescript
|
|
152
253
|
interface Decision {
|
|
153
|
-
status: "APPROVED" | "REJECTED";
|
|
254
|
+
status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
|
|
154
255
|
payload: Record<string, any>; // Original payload sent by agent
|
|
155
256
|
patched_payload?: Record<string, any>; // Human-edited values (if modified)
|
|
156
|
-
|
|
257
|
+
diff_summary?: any; // Field-level diff between payload and patched_payload
|
|
258
|
+
metadata?: {
|
|
157
259
|
actor_id: string; // ID/email of the approving/rejecting human
|
|
158
260
|
resolved_at: string; // ISO 8601 timestamp
|
|
261
|
+
method?: string; // Optional resolution method (e.g. "dashboard")
|
|
159
262
|
};
|
|
160
263
|
}
|
|
161
264
|
```
|
|
@@ -163,4 +266,79 @@ interface Decision {
|
|
|
163
266
|
For full documentation, request schema examples, error codes, and dashboard integration see:
|
|
164
267
|
https://letsping.co/docs#sdk
|
|
165
268
|
|
|
166
|
-
|
|
269
|
+
### Agent-to-Agent Escrow (optional)
|
|
270
|
+
|
|
271
|
+
For multi-agent systems that want cryptographic guarantees around handoffs, the SDK exposes:
|
|
272
|
+
|
|
273
|
+
- `signAgentCall(agentId, secret, call)` to attach `agent_id` and `agent_signature` to `/ingest` calls.
|
|
274
|
+
- `signIngestBody(agentId, secret, body)` to take an existing ingest body (`{ project_id, service, action, payload }`) and return it with `agent_id` and `agent_signature` attached.
|
|
275
|
+
- `verifyEscrow(event, secret)` to validate LetsPing escrow webhooks.
|
|
276
|
+
- `chainHandoff(previous, nextData, secret)` to safely construct downstream handoffs tied to the original request id.
|
|
277
|
+
|
|
278
|
+
See the one-page spec at `/docs/agent-escrow-spec` in the LetsPing web app for the exact wire format and interoperability rules.
|
|
279
|
+
|
|
280
|
+
Deploy agents with confidence.
|
|
281
|
+
|
|
282
|
+
## 2-Minute Demo (Node/TypeScript)
|
|
283
|
+
|
|
284
|
+
You can feel the full LetsPing loop (intercept → approve → resume) in under 2 minutes.
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
// demo.ts
|
|
288
|
+
import { LetsPing } from "@letsping/sdk";
|
|
289
|
+
|
|
290
|
+
async function main() {
|
|
291
|
+
const apiKey = process.env.LETSPING_API_KEY;
|
|
292
|
+
if (!apiKey) {
|
|
293
|
+
console.error("Missing LETSPING_API_KEY env var.");
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const lp = new LetsPing(apiKey);
|
|
298
|
+
|
|
299
|
+
console.log("Sending demo approval request to LetsPing…");
|
|
300
|
+
const decision = await lp.ask({
|
|
301
|
+
service: "demo-agent",
|
|
302
|
+
action: "transfer_funds",
|
|
303
|
+
priority: "high",
|
|
304
|
+
payload: {
|
|
305
|
+
amount: 500,
|
|
306
|
+
currency: "USD",
|
|
307
|
+
recipient: "acct_demo_123",
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (decision.status === "REJECTED") {
|
|
312
|
+
console.log("Demo request REJECTED by human. No action taken.");
|
|
313
|
+
} else if (decision.status === "APPROVED_WITH_MODIFICATIONS") {
|
|
314
|
+
console.log("APPROVED WITH MODIFICATIONS:");
|
|
315
|
+
console.dir(decision.diff_summary, { depth: null });
|
|
316
|
+
} else {
|
|
317
|
+
console.log("APPROVED with original payload.");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
main().catch((err) => {
|
|
322
|
+
console.error("Demo failed:", err);
|
|
323
|
+
process.exit(1);
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Run:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
export LETSPING_API_KEY="lp_live_..."
|
|
331
|
+
node demo.ts
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Then open the LetsPing dashboard for your project, approve/reject the `demo-agent / transfer_funds` request, and watch the script resume.
|
|
335
|
+
|
|
336
|
+
If you’re using the local tunnel (`npx @letsping/cli dev`), you can also point the SDK at it during local development:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
const lp = new LetsPing(process.env.LETSPING_API_KEY!, {
|
|
340
|
+
baseUrl: "http://localhost:<port>/api",
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
All `ask` / `defer` calls made through that client will flow through your local tunnel into the LetsPing dashboard.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { StateGraph, START } from "@langchain/langgraph";
|
|
2
|
+
import { LetsPing } from "@letsping/sdk";
|
|
3
|
+
import { LetsPingCheckpointer } from "@letsping/sdk/integrations/langgraph";
|
|
4
|
+
|
|
5
|
+
type DemoState = {
|
|
6
|
+
thread_id: string;
|
|
7
|
+
step: "START" | "NEEDS_APPROVAL" | "DONE";
|
|
8
|
+
amount: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const lp = new LetsPing(process.env.LETSPING_API_KEY!);
|
|
12
|
+
const checkpointer = new LetsPingCheckpointer(lp);
|
|
13
|
+
|
|
14
|
+
// Chain the methods directly off the instantiation
|
|
15
|
+
const builder = new StateGraph<DemoState>({
|
|
16
|
+
channels: {
|
|
17
|
+
thread_id: null,
|
|
18
|
+
step: null,
|
|
19
|
+
amount: null,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
.addNode("charge_step", async (state: DemoState): Promise<DemoState> => {
|
|
23
|
+
// On the first pass, ask LetsPing for approval and park state.
|
|
24
|
+
if (state.step === "START") {
|
|
25
|
+
const decision = await lp.defer({
|
|
26
|
+
service: "demo-agent",
|
|
27
|
+
action: "payments:charge",
|
|
28
|
+
priority: "high",
|
|
29
|
+
payload: { amount: state.amount },
|
|
30
|
+
// Persist enough context so the webhook can resume the same thread.
|
|
31
|
+
state_snapshot: {
|
|
32
|
+
thread_id: state.thread_id,
|
|
33
|
+
input: state,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log("Queued LetsPing request id:", decision.id);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...state,
|
|
41
|
+
step: "NEEDS_APPROVAL",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// After approval + webhook resume, the graph will be invoked again
|
|
46
|
+
if (state.step === "NEEDS_APPROVAL") {
|
|
47
|
+
console.log("Approval received. Performing final charge for:", state.amount);
|
|
48
|
+
return {
|
|
49
|
+
...state,
|
|
50
|
+
step: "DONE",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return state;
|
|
55
|
+
})
|
|
56
|
+
// Notice we are chaining addEdge directly after addNode
|
|
57
|
+
.addEdge(START, "charge_step");
|
|
58
|
+
|
|
59
|
+
export const demoGraph = builder.compile({ checkpointer });
|
|
60
|
+
|
|
61
|
+
if (require.main === module) {
|
|
62
|
+
(async () => {
|
|
63
|
+
const threadId = `demo-${Date.now()}`;
|
|
64
|
+
console.log("Starting demo thread:", threadId);
|
|
65
|
+
|
|
66
|
+
await demoGraph.invoke(
|
|
67
|
+
{
|
|
68
|
+
thread_id: threadId,
|
|
69
|
+
step: "START",
|
|
70
|
+
amount: 500,
|
|
71
|
+
},
|
|
72
|
+
{ configurable: { thread_id: threadId } },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
console.log("Demo graph invoked. Wait for LetsPing approval, then webhook will resume.");
|
|
76
|
+
})().catch((e) => {
|
|
77
|
+
console.error(e);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
80
|
+
}
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -8,15 +8,67 @@ export interface RequestOptions {
|
|
|
8
8
|
timeoutMs?: number;
|
|
9
9
|
}
|
|
10
10
|
export interface Decision {
|
|
11
|
-
status: "APPROVED" | "REJECTED";
|
|
11
|
+
status: "APPROVED" | "REJECTED" | "APPROVED_WITH_MODIFICATIONS";
|
|
12
12
|
payload: any;
|
|
13
13
|
patched_payload?: any;
|
|
14
|
+
diff_summary?: any;
|
|
14
15
|
metadata?: {
|
|
15
16
|
resolved_at: string;
|
|
16
17
|
actor_id: string;
|
|
17
18
|
method?: string;
|
|
18
19
|
};
|
|
19
20
|
}
|
|
21
|
+
export interface EscrowEnvelope {
|
|
22
|
+
id: string;
|
|
23
|
+
event: string;
|
|
24
|
+
data: any;
|
|
25
|
+
escrow?: {
|
|
26
|
+
mode: "none" | "handoff" | "finalized";
|
|
27
|
+
handoff_signature: string | null;
|
|
28
|
+
upstream_agent_id: string | null;
|
|
29
|
+
downstream_agent_id: string | null;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare function verifyEscrow(event: EscrowEnvelope, secret: string): boolean;
|
|
33
|
+
export interface AgentCallPayload {
|
|
34
|
+
project_id: string;
|
|
35
|
+
service: string;
|
|
36
|
+
action: string;
|
|
37
|
+
payload: any;
|
|
38
|
+
}
|
|
39
|
+
export declare function signAgentCall(agentId: string, secret: string, call: AgentCallPayload): {
|
|
40
|
+
agent_id: string;
|
|
41
|
+
agent_signature: string;
|
|
42
|
+
};
|
|
43
|
+
export declare function signIngestBody(agentId: string, secret: string, body: {
|
|
44
|
+
project_id: string;
|
|
45
|
+
service: string;
|
|
46
|
+
action: string;
|
|
47
|
+
payload: any;
|
|
48
|
+
}): {
|
|
49
|
+
project_id: string;
|
|
50
|
+
service: string;
|
|
51
|
+
action: string;
|
|
52
|
+
payload: any;
|
|
53
|
+
agent_id: string;
|
|
54
|
+
agent_signature: string;
|
|
55
|
+
};
|
|
56
|
+
export declare function verifyAgentSignature(agentId: string, secret: string, call: AgentCallPayload, signature: string): boolean;
|
|
57
|
+
export declare function chainHandoff(previous: EscrowEnvelope, nextData: {
|
|
58
|
+
service: string;
|
|
59
|
+
action: string;
|
|
60
|
+
payload: any;
|
|
61
|
+
upstream_agent_id: string;
|
|
62
|
+
downstream_agent_id: string;
|
|
63
|
+
}, secret: string): {
|
|
64
|
+
payload: any;
|
|
65
|
+
escrow: {
|
|
66
|
+
mode: "handoff";
|
|
67
|
+
upstream_agent_id: string;
|
|
68
|
+
downstream_agent_id: string;
|
|
69
|
+
handoff_signature: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
20
72
|
export declare class LetsPingError extends Error {
|
|
21
73
|
status?: number | undefined;
|
|
22
74
|
constructor(message: string, status?: number | undefined);
|
|
@@ -26,10 +78,15 @@ export declare class LetsPing {
|
|
|
26
78
|
private readonly baseUrl;
|
|
27
79
|
constructor(apiKey?: string, options?: {
|
|
28
80
|
baseUrl?: string;
|
|
81
|
+
encryptionKey?: string;
|
|
29
82
|
});
|
|
30
83
|
ask(options: RequestOptions): Promise<Decision>;
|
|
31
84
|
defer(options: RequestOptions): Promise<{
|
|
32
85
|
id: string;
|
|
33
86
|
}>;
|
|
87
|
+
waitForDecision(id: string, options?: {
|
|
88
|
+
originalPayload?: Record<string, any>;
|
|
89
|
+
timeoutMs?: number;
|
|
90
|
+
}): Promise<Decision>;
|
|
34
91
|
private request;
|
|
35
92
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes, createHmac } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
let SDK_VERSION = "0.1
|
|
3
|
+
let SDK_VERSION = "0.2.1";
|
|
4
4
|
try {
|
|
5
5
|
|
|
6
6
|
SDK_VERSION = require("../package.json").version;
|
|
@@ -37,6 +37,14 @@ export interface RequestOptions {
|
|
|
37
37
|
timeoutMs?: number;
|
|
38
38
|
|
|
39
39
|
role?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Optional distributed tracing identifiers. If provided, these will be
|
|
43
|
+
* attached to the request envelope so downstream frameworks can stitch
|
|
44
|
+
* together multi-agent flows.
|
|
45
|
+
*/
|
|
46
|
+
trace_id?: string;
|
|
47
|
+
parent_request_id?: string;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
export interface Decision {
|
|
@@ -145,6 +153,129 @@ function computeDiff(original: any, patched: any): any {
|
|
|
145
153
|
return hasChanges ? changes : null;
|
|
146
154
|
}
|
|
147
155
|
|
|
156
|
+
export interface EscrowEnvelope {
|
|
157
|
+
id: string;
|
|
158
|
+
event: string;
|
|
159
|
+
data: any;
|
|
160
|
+
escrow?: {
|
|
161
|
+
mode: "none" | "handoff" | "finalized";
|
|
162
|
+
handoff_signature: string | null;
|
|
163
|
+
upstream_agent_id: string | null;
|
|
164
|
+
downstream_agent_id: string | null;
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function verifyEscrow(event: EscrowEnvelope, secret: string): boolean {
|
|
169
|
+
if (!event.escrow || !event.escrow.handoff_signature) return false;
|
|
170
|
+
const base = {
|
|
171
|
+
id: event.id,
|
|
172
|
+
event: event.event,
|
|
173
|
+
data: event.data,
|
|
174
|
+
upstream_agent_id: event.escrow.upstream_agent_id,
|
|
175
|
+
downstream_agent_id: event.escrow.downstream_agent_id,
|
|
176
|
+
};
|
|
177
|
+
const expected = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
|
|
178
|
+
return expected === event.escrow.handoff_signature;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface AgentCallPayload {
|
|
182
|
+
project_id: string;
|
|
183
|
+
service: string;
|
|
184
|
+
action: string;
|
|
185
|
+
payload: any;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function signAgentCall(agentId: string, secret: string, call: AgentCallPayload): {
|
|
189
|
+
agent_id: string;
|
|
190
|
+
agent_signature: string;
|
|
191
|
+
} {
|
|
192
|
+
const canonical = JSON.stringify({
|
|
193
|
+
project_id: call.project_id,
|
|
194
|
+
service: call.service,
|
|
195
|
+
action: call.action,
|
|
196
|
+
payload: call.payload,
|
|
197
|
+
});
|
|
198
|
+
const signature = createHmac("sha256", secret).update(canonical).digest("hex");
|
|
199
|
+
return {
|
|
200
|
+
agent_id: agentId,
|
|
201
|
+
agent_signature: signature,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function signIngestBody(
|
|
206
|
+
agentId: string,
|
|
207
|
+
secret: string,
|
|
208
|
+
body: {
|
|
209
|
+
project_id: string;
|
|
210
|
+
service: string;
|
|
211
|
+
action: string;
|
|
212
|
+
payload: any;
|
|
213
|
+
}
|
|
214
|
+
): {
|
|
215
|
+
project_id: string;
|
|
216
|
+
service: string;
|
|
217
|
+
action: string;
|
|
218
|
+
payload: any;
|
|
219
|
+
agent_id: string;
|
|
220
|
+
agent_signature: string;
|
|
221
|
+
} {
|
|
222
|
+
const { agent_id, agent_signature } = signAgentCall(agentId, secret, {
|
|
223
|
+
project_id: body.project_id,
|
|
224
|
+
service: body.service,
|
|
225
|
+
action: body.action,
|
|
226
|
+
payload: body.payload,
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
...body,
|
|
230
|
+
agent_id,
|
|
231
|
+
agent_signature,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function verifyAgentSignature(
|
|
236
|
+
agentId: string,
|
|
237
|
+
secret: string,
|
|
238
|
+
call: AgentCallPayload,
|
|
239
|
+
signature: string
|
|
240
|
+
): boolean {
|
|
241
|
+
const { agent_signature } = signAgentCall(agentId, secret, call);
|
|
242
|
+
return agent_signature === signature;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function chainHandoff(previous: EscrowEnvelope, nextData: {
|
|
246
|
+
service: string;
|
|
247
|
+
action: string;
|
|
248
|
+
payload: any;
|
|
249
|
+
upstream_agent_id: string;
|
|
250
|
+
downstream_agent_id: string;
|
|
251
|
+
}, secret: string): {
|
|
252
|
+
payload: any;
|
|
253
|
+
escrow: {
|
|
254
|
+
mode: "handoff";
|
|
255
|
+
upstream_agent_id: string;
|
|
256
|
+
downstream_agent_id: string;
|
|
257
|
+
handoff_signature: string;
|
|
258
|
+
};
|
|
259
|
+
} {
|
|
260
|
+
const base = {
|
|
261
|
+
id: previous.id,
|
|
262
|
+
event: previous.event,
|
|
263
|
+
data: nextData.payload,
|
|
264
|
+
upstream_agent_id: nextData.upstream_agent_id,
|
|
265
|
+
downstream_agent_id: nextData.downstream_agent_id,
|
|
266
|
+
};
|
|
267
|
+
const handoff_signature = createHmac("sha256", secret).update(JSON.stringify(base)).digest("hex");
|
|
268
|
+
return {
|
|
269
|
+
payload: nextData.payload,
|
|
270
|
+
escrow: {
|
|
271
|
+
mode: "handoff",
|
|
272
|
+
upstream_agent_id: nextData.upstream_agent_id,
|
|
273
|
+
downstream_agent_id: nextData.downstream_agent_id,
|
|
274
|
+
handoff_signature,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
148
279
|
export class LetsPing {
|
|
149
280
|
private readonly apiKey: string;
|
|
150
281
|
private readonly baseUrl: string;
|
|
@@ -222,14 +353,30 @@ export class LetsPing {
|
|
|
222
353
|
});
|
|
223
354
|
}
|
|
224
355
|
|
|
356
|
+
const traceId = options.trace_id;
|
|
357
|
+
const parentId = options.parent_request_id;
|
|
358
|
+
|
|
359
|
+
// Do not mutate caller payload; attach tracing metadata under a reserved key.
|
|
360
|
+
const basePayload = options.payload || {};
|
|
361
|
+
const metaKey = "_lp_meta";
|
|
362
|
+
const existingMeta = (basePayload as any)[metaKey] || {};
|
|
363
|
+
const enrichedPayload = {
|
|
364
|
+
...basePayload,
|
|
365
|
+
[metaKey]: {
|
|
366
|
+
...existingMeta,
|
|
367
|
+
...(traceId ? { trace_id: traceId } : {}),
|
|
368
|
+
...(parentId ? { parent_request_id: parentId } : {}),
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
|
|
225
372
|
try {
|
|
226
373
|
const res = await this.request<{ id: string, uploadUrl?: string, dek?: string }>("POST", "/ingest", {
|
|
227
374
|
service: options.service,
|
|
228
375
|
action: options.action,
|
|
229
|
-
payload: this._encrypt(
|
|
376
|
+
payload: this._encrypt(enrichedPayload),
|
|
230
377
|
priority: options.priority || "medium",
|
|
231
378
|
schema: options.schema,
|
|
232
|
-
metadata: { role: options.role, sdk: "node" }
|
|
379
|
+
metadata: { role: options.role, sdk: "node", trace_id: traceId, parent_request_id: parentId }
|
|
233
380
|
});
|
|
234
381
|
|
|
235
382
|
const { id, uploadUrl, dek } = res;
|
|
@@ -325,10 +472,28 @@ export class LetsPing {
|
|
|
325
472
|
});
|
|
326
473
|
}
|
|
327
474
|
|
|
475
|
+
const traceId = options.trace_id;
|
|
476
|
+
const parentId = options.parent_request_id;
|
|
477
|
+
const basePayload = options.payload || {};
|
|
478
|
+
const metaKey = "_lp_meta";
|
|
479
|
+
const existingMeta = (basePayload as any)[metaKey] || {};
|
|
480
|
+
const enrichedPayload = {
|
|
481
|
+
...basePayload,
|
|
482
|
+
[metaKey]: {
|
|
483
|
+
...existingMeta,
|
|
484
|
+
...(traceId ? { trace_id: traceId } : {}),
|
|
485
|
+
...(parentId ? { parent_request_id: parentId } : {}),
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
|
|
328
489
|
try {
|
|
329
490
|
const res = await this.request<{ id: string, uploadUrl?: string, dek?: string }>("POST", "/ingest", {
|
|
330
|
-
|
|
331
|
-
|
|
491
|
+
service: options.service,
|
|
492
|
+
action: options.action,
|
|
493
|
+
payload: this._encrypt(enrichedPayload),
|
|
494
|
+
priority: options.priority || "medium",
|
|
495
|
+
schema: options.schema,
|
|
496
|
+
metadata: { role: options.role, sdk: "node", trace_id: traceId, parent_request_id: parentId },
|
|
332
497
|
});
|
|
333
498
|
if (res.uploadUrl && options.state_snapshot) {
|
|
334
499
|
try {
|
|
@@ -392,6 +557,56 @@ export class LetsPing {
|
|
|
392
557
|
}
|
|
393
558
|
}
|
|
394
559
|
|
|
560
|
+
async waitForDecision(
|
|
561
|
+
id: string,
|
|
562
|
+
options?: { originalPayload?: Record<string, any>; timeoutMs?: number }
|
|
563
|
+
): Promise<Decision> {
|
|
564
|
+
const basePayload = options?.originalPayload || {};
|
|
565
|
+
const timeout = options?.timeoutMs || 24 * 60 * 60 * 1000;
|
|
566
|
+
const start = Date.now();
|
|
567
|
+
let delay = 1000;
|
|
568
|
+
const maxDelay = 10000;
|
|
569
|
+
|
|
570
|
+
while (Date.now() - start < timeout) {
|
|
571
|
+
try {
|
|
572
|
+
const check = await this.request<any>("GET", `/status/${id}`);
|
|
573
|
+
|
|
574
|
+
if (check.status === "APPROVED" || check.status === "REJECTED") {
|
|
575
|
+
const decryptedPayload = this._decrypt(check.payload) ?? basePayload;
|
|
576
|
+
const decryptedPatched = check.patched_payload ? this._decrypt(check.patched_payload) : undefined;
|
|
577
|
+
|
|
578
|
+
let diff_summary;
|
|
579
|
+
let finalStatus: Decision["status"] = check.status;
|
|
580
|
+
if (check.status === "APPROVED" && decryptedPatched !== undefined) {
|
|
581
|
+
finalStatus = "APPROVED_WITH_MODIFICATIONS";
|
|
582
|
+
const diff = computeDiff(decryptedPayload, decryptedPatched);
|
|
583
|
+
diff_summary = diff ? { changes: diff } : { changes: "Unknown structure changes" };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return {
|
|
587
|
+
status: finalStatus,
|
|
588
|
+
payload: decryptedPayload,
|
|
589
|
+
patched_payload: decryptedPatched,
|
|
590
|
+
diff_summary,
|
|
591
|
+
metadata: {
|
|
592
|
+
resolved_at: check.resolved_at,
|
|
593
|
+
actor_id: check.actor_id,
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
} catch (e: any) {
|
|
598
|
+
const s = e.status;
|
|
599
|
+
if (s && s >= 400 && s < 500 && s !== 404 && s !== 429) throw e;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const jitter = Math.random() * 200;
|
|
603
|
+
await new Promise(r => setTimeout(r, delay + jitter));
|
|
604
|
+
delay = Math.min(delay * 1.5, maxDelay);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
throw new LetsPingError(`Request ${id} timed out waiting for approval.`);
|
|
608
|
+
}
|
|
609
|
+
|
|
395
610
|
tool(service: string, action: string, priority: Priority = "medium"): (context: string | Record<string, any>) => Promise<string> {
|
|
396
611
|
return async (context: string | Record<string, any>): Promise<string> => {
|
|
397
612
|
let payload: Record<string, any>;
|
|
@@ -436,11 +651,29 @@ export class LetsPing {
|
|
|
436
651
|
signatureHeader: string,
|
|
437
652
|
webhookSecret: string
|
|
438
653
|
): Promise<{ id: string; event: string; data: Decision; state_snapshot?: Record<string, any> }> {
|
|
439
|
-
const hmac = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
|
|
440
654
|
const sigParts = signatureHeader.split(",").map(p => p.split("="));
|
|
441
655
|
const sigMap = Object.fromEntries(sigParts);
|
|
442
656
|
|
|
443
|
-
|
|
657
|
+
const rawTs = sigMap["t"];
|
|
658
|
+
const rawSig = sigMap["v1"];
|
|
659
|
+
if (!rawTs || !rawSig) {
|
|
660
|
+
throw new LetsPingError("LetsPing Error: Missing webhook signature fields", 401);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const ts = Number(rawTs);
|
|
664
|
+
if (!Number.isFinite(ts)) {
|
|
665
|
+
throw new LetsPingError("LetsPing Error: Invalid webhook timestamp", 401);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const skewMs = Math.abs(now - ts);
|
|
670
|
+
const maxSkewMs = 5 * 60 * 1000; // 5 minutes
|
|
671
|
+
if (skewMs > maxSkewMs) {
|
|
672
|
+
throw new LetsPingError("LetsPing Error: Webhook replay window exceeded", 401);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const expected = createHmac("sha256", webhookSecret).update(payloadStr).digest("hex");
|
|
676
|
+
if (rawSig !== expected) {
|
|
444
677
|
throw new LetsPingError("LetsPing Error: Invalid webhook signature", 401);
|
|
445
678
|
}
|
|
446
679
|
|
|
@@ -2,13 +2,84 @@ import { BaseCheckpointSaver, Checkpoint, CheckpointMetadata, CheckpointTuple }
|
|
|
2
2
|
import { RunnableConfig } from "@langchain/core/runnables";
|
|
3
3
|
import { LetsPing } from "../index";
|
|
4
4
|
|
|
5
|
+
type StoredCheckpoint = {
|
|
6
|
+
checkpoint: Checkpoint;
|
|
7
|
+
metadata: CheckpointMetadata;
|
|
8
|
+
};
|
|
9
|
+
|
|
5
10
|
export class LetsPingCheckpointer extends BaseCheckpointSaver {
|
|
6
|
-
private checkpoints: Record<string,
|
|
11
|
+
private checkpoints: Record<string, StoredCheckpoint> = {};
|
|
7
12
|
|
|
8
13
|
constructor(public client: LetsPing) {
|
|
9
14
|
super();
|
|
10
15
|
}
|
|
11
16
|
|
|
17
|
+
private getTransport(): (<T = any>(method: string, path: string, body?: any) => Promise<T>) | null {
|
|
18
|
+
const clientAny = this.client as any;
|
|
19
|
+
if (typeof clientAny.request === "function") {
|
|
20
|
+
return clientAny.request.bind(this.client);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private async saveRemote(
|
|
26
|
+
threadId: string,
|
|
27
|
+
checkpointId: string,
|
|
28
|
+
checkpoint: Checkpoint,
|
|
29
|
+
metadata: CheckpointMetadata
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const transport = this.getTransport();
|
|
32
|
+
if (!transport) {
|
|
33
|
+
console.warn("[LetsPingCheckpointer] Missing underlying transport; falling back to in-memory only.");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await transport("POST", "/langgraph/checkpoints", {
|
|
38
|
+
thread_id: threadId,
|
|
39
|
+
checkpoint_id: checkpointId,
|
|
40
|
+
checkpoint,
|
|
41
|
+
metadata,
|
|
42
|
+
});
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.warn("[LetsPingCheckpointer] Failed to persist checkpoint remotely; falling back to in-memory only.", e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async loadRemote(
|
|
49
|
+
threadId: string,
|
|
50
|
+
checkpointId?: string
|
|
51
|
+
): Promise<StoredCheckpoint | null> {
|
|
52
|
+
const transport = this.getTransport();
|
|
53
|
+
if (!transport) {
|
|
54
|
+
console.warn("[LetsPingCheckpointer] Missing underlying transport; using in-memory checkpoints only.");
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const search = checkpointId
|
|
58
|
+
? `?thread_id=${encodeURIComponent(threadId)}&checkpoint_id=${encodeURIComponent(checkpointId)}`
|
|
59
|
+
: `?thread_id=${encodeURIComponent(threadId)}&latest=1`;
|
|
60
|
+
try {
|
|
61
|
+
const res = await transport<any>("GET", `/langgraph/checkpoints${search}`);
|
|
62
|
+
if (res && res.checkpoint && res.metadata) {
|
|
63
|
+
return { checkpoint: res.checkpoint as Checkpoint, metadata: res.metadata as CheckpointMetadata };
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// If not found or backend unavailable, fall back to local cache only.
|
|
67
|
+
console.warn("[LetsPingCheckpointer] Failed to load remote checkpoint", e);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async deleteRemote(threadId: string): Promise<void> {
|
|
73
|
+
const transport = this.getTransport();
|
|
74
|
+
if (!transport) return;
|
|
75
|
+
const search = `?thread_id=${encodeURIComponent(threadId)}`;
|
|
76
|
+
try {
|
|
77
|
+
await transport("DELETE", `/langgraph/checkpoints${search}`);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.warn("[LetsPingCheckpointer] Failed to delete remote checkpoints", e);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
12
83
|
async put(
|
|
13
84
|
config: RunnableConfig,
|
|
14
85
|
checkpoint: Checkpoint,
|
|
@@ -18,17 +89,22 @@ export class LetsPingCheckpointer extends BaseCheckpointSaver {
|
|
|
18
89
|
const threadId = config.configurable?.thread_id;
|
|
19
90
|
const checkpointId = checkpoint.id;
|
|
20
91
|
|
|
21
|
-
|
|
92
|
+
if (!threadId || !checkpointId) {
|
|
93
|
+
return config;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.checkpoints[`${threadId}:${checkpointId}`] = { checkpoint, metadata };
|
|
97
|
+
await this.saveRemote(threadId, checkpointId, checkpoint, metadata);
|
|
22
98
|
|
|
23
99
|
return {
|
|
24
100
|
configurable: {
|
|
25
101
|
thread_id: threadId,
|
|
26
102
|
checkpoint_id: checkpointId,
|
|
27
|
-
}
|
|
103
|
+
},
|
|
28
104
|
};
|
|
29
105
|
}
|
|
30
106
|
|
|
31
|
-
//
|
|
107
|
+
// METHODS REQUIRED BY LANGGRAPH V0.1+
|
|
32
108
|
async putWrites(config: RunnableConfig, writes: any, taskId: string): Promise<void> {
|
|
33
109
|
// No-op for V1: LetsPing focuses on primary state parking, not granular sub-task writes.
|
|
34
110
|
}
|
|
@@ -39,27 +115,49 @@ export class LetsPingCheckpointer extends BaseCheckpointSaver {
|
|
|
39
115
|
delete this.checkpoints[key];
|
|
40
116
|
}
|
|
41
117
|
}
|
|
118
|
+
await this.deleteRemote(threadId);
|
|
42
119
|
}
|
|
43
120
|
|
|
44
121
|
async getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined> {
|
|
45
122
|
const threadId = config.configurable?.thread_id;
|
|
46
123
|
const checkpointId = config.configurable?.checkpoint_id;
|
|
124
|
+
if (!threadId) return undefined;
|
|
125
|
+
|
|
126
|
+
// Prefer remote truth, fall back to local cache.
|
|
127
|
+
const remote = await this.loadRemote(threadId, checkpointId);
|
|
128
|
+
if (remote) {
|
|
129
|
+
return { config, checkpoint: remote.checkpoint, metadata: remote.metadata };
|
|
130
|
+
}
|
|
47
131
|
|
|
48
132
|
if (checkpointId) {
|
|
49
133
|
const match = this.checkpoints[`${threadId}:${checkpointId}`];
|
|
50
|
-
if (match)
|
|
134
|
+
if (match) {
|
|
135
|
+
return { config, checkpoint: match.checkpoint, metadata: match.metadata };
|
|
136
|
+
}
|
|
51
137
|
}
|
|
52
138
|
|
|
53
139
|
let latest: CheckpointTuple | undefined;
|
|
54
140
|
for (const [key, val] of Object.entries(this.checkpoints)) {
|
|
55
141
|
if (key.startsWith(`${threadId}:`)) {
|
|
56
|
-
latest = { config, checkpoint: val
|
|
142
|
+
latest = { config, checkpoint: val.checkpoint, metadata: val.metadata };
|
|
57
143
|
}
|
|
58
144
|
}
|
|
59
145
|
return latest;
|
|
60
146
|
}
|
|
61
147
|
|
|
62
148
|
async *list(config: RunnableConfig, options?: any): AsyncGenerator<CheckpointTuple> {
|
|
63
|
-
|
|
149
|
+
const threadId = config.configurable?.thread_id;
|
|
150
|
+
if (!threadId) return;
|
|
151
|
+
|
|
152
|
+
const remoteLatest = await this.loadRemote(threadId);
|
|
153
|
+
if (remoteLatest) {
|
|
154
|
+
yield { config, checkpoint: remoteLatest.checkpoint, metadata: remoteLatest.metadata };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const [key, val] of Object.entries(this.checkpoints)) {
|
|
158
|
+
if (key.startsWith(`${threadId}:`)) {
|
|
159
|
+
yield { config, checkpoint: val.checkpoint, metadata: val.metadata };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
64
162
|
}
|
|
65
163
|
}
|