@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 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-service",
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 (Framework Agnostic)
96
- LetsPing does **not** magically inject state back into your framework natively. You must handle the webhook and rehydrate your specific framework manually.
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
- // app/api/webhook/letsping/route.ts
100
- if (body.status === "APPROVED") {
101
- let hydratedState = null;
102
- if (body.state_download_url) {
103
- const res = await fetch(body.state_download_url);
104
- hydratedState = lp._decrypt(await res.json());
105
- }
106
- // Manually push `hydratedState` back into your LangGraph/Vercel thread
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 that automatically encrypts and parks your agent's state in Cryo-Sleep storage.
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
- // Import the checkpointer from the specific integration path
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
- metadata: {
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
- Deploy agents with confidence.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letsping/sdk",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Behavioral Firewall and Cryo-Sleep State Parking for Autonomous Agents",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
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.6";
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(options.payload),
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
- ...options,
331
- payload: this._encrypt(options.payload),
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
- if (sigMap["v1"] !== hmac) {
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, [Checkpoint, CheckpointMetadata]> = {};
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
- this.checkpoints[`${threadId}:${checkpointId}`] = [checkpoint, metadata];
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
- // --- NEW METHODS REQUIRED BY LANGGRAPH V0.1+ ---
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) return { config, checkpoint: match[0], metadata: match[1] };
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[0], metadata: val[1] };
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
- yield* [];
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
  }