Skip to content

Commit 343e6c3

Browse files
committed
Release v0.14.2
Origin-SHA: e6e7921206f16bd682632e00653f174f0eff332f
1 parent 6ccc884 commit 343e6c3

6 files changed

Lines changed: 48 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# @opensea/tool-sdk
22

3+
## 0.14.2
4+
5+
### Patch Changes
6+
7+
- d7c1191: Await the `usageReporting` reporter before returning the tool response.
8+
9+
Previously the handler fired the reporter fire-and-forget after the response was built. On serverless runtimes (Vercel, AWS Lambda) the function is frozen the moment the response flushes, which killed the in-flight request so the usage report silently never arrived. The handler now awaits the report (bounded by the reporter's `timeoutMs`, default 5s) so it reliably completes; failures are still caught and logged and never fail the tool call.
10+
311
## 0.14.1
412

513
### Patch Changes

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ const handler = createToolHandler({
876876

877877
> **Reporting is the service's job, never the caller's.** The `apiKey` authenticates *you* (the operator) as the reporter. The caller is identified by data they already supplied (the x402 payer, or the EIP-3009 authorization `predicateGate` verified), so no caller wallet, signing, or `walletClient` is involved.
878878
879-
That's it: the handler fires the reporter as a fire-and-forget async call at the very end of the lifecycle, after the response is built. It never blocks or fails the tool call.
879+
That's it: the handler runs the reporter at the very end of the lifecycle, after the output is computed and gates have settled. It is **awaited** before the response is returned (bounded by the reporter's `timeoutMs`, default 5s) so the report reliably completes even on serverless runtimes that freeze the function the moment the response flushes. Reporter failures are caught and logged; they never fail the tool call.
880880

881881
### How it works
882882

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@opensea/tool-sdk",
3-
"version": "0.14.1",
3+
"version": "0.14.2",
44
"type": "module",
55
"description": "SDK and CLI for building ERC-8257 compliant AI agent tools",
66
"repository": {

skill/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ Tool-sdk reports usage to OpenSea's analytics endpoint (`POST /api/v2/tools/usag
381381

382382
### usageReporting (recommended)
383383

384-
Pass `usageReporting` to `createToolHandler` and it fires the reporter as a fire-and-forget call at the very end of the lifecycle (after the response is built). No `walletClient` is needed server-side:
384+
Pass `usageReporting` to `createToolHandler` and it runs the reporter at the very end of the lifecycle, awaited before the response returns (bounded by `timeoutMs`, default 5s) so it completes even on serverless runtimes that freeze on response flush. Failures are logged, never fatal. No `walletClient` is needed server-side:
385385

386386
- **Paid x402 calls**`verification_type: "x402_settlement"` with the payer address and settlement tx hash. The backend verifies the tx directly.
387387
- **EIP-3009-authenticated calls** (behind `predicateGate`) → `verification_type: "eip3009_authorization"`, **forwarding the caller's original signed authorization**. The caller already signed it to authenticate, so the reported identity is the real caller.

src/__tests__/handler.test.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ describe("createToolHandler", () => {
489489
expect(order).toEqual(["handler", "settle", "onInvocation"])
490490
})
491491

492-
it("fires usageReporting as fire-and-forget on successful invocation", async () => {
492+
it("fires usageReporting on successful invocation", async () => {
493493
const { createEip3009UsageReporter } = await import(
494494
"../lib/usage/eip3009-reporter.js"
495495
)
@@ -522,45 +522,57 @@ describe("createToolHandler", () => {
522522
)
523523
expect(response.status).toBe(200)
524524

525-
// flush fire-and-forget microtask
526-
await vi.waitFor(() => expect(mockReporter).toHaveBeenCalledOnce())
525+
// Awaited before the response resolves — no waitFor needed.
526+
expect(mockReporter).toHaveBeenCalledOnce()
527527
const event: InvocationEvent = mockReporter.mock.calls[0]![0]
528528
expect(event.paid).toBe(false)
529529
expect(event.latencyMs).toBeGreaterThanOrEqual(0)
530530
})
531531

532-
it("fires usageReporting after response is built", async () => {
532+
it("awaits usageReporting before returning the response", async () => {
533+
// Regression guard: on serverless the function freezes once the response
534+
// flushes, so the report must complete *before* the handler resolves, not
535+
// fire-and-forget after.
533536
const { createEip3009UsageReporter } = await import(
534537
"../lib/usage/eip3009-reporter.js"
535538
)
536-
let reporterCalledBeforeReturn = false
537-
const mockReporter = vi.fn().mockResolvedValue(undefined)
538-
vi.mocked(createEip3009UsageReporter).mockReturnValue(mockReporter)
539-
const onInvocation = vi.fn(() => {
540-
reporterCalledBeforeReturn = mockReporter.mock.calls.length > 0
539+
let releaseReport: () => void = () => {}
540+
const reportGate = new Promise<void>(resolve => {
541+
releaseReport = resolve
541542
})
543+
const mockReporter = vi.fn(() => reportGate)
544+
vi.mocked(createEip3009UsageReporter).mockReturnValue(mockReporter)
542545

543546
const handler = createToolHandler({
544547
manifest: testManifest,
545548
inputSchema: InputSchema,
546549
outputSchema: OutputSchema,
547550
handler: async input => ({ result: `Echo: ${input.query}` }),
548551
usageReporting: {} as unknown as Eip3009UsageReporterConfig,
549-
onInvocation,
550552
})
551553

552-
const response = await handler(
554+
let settled = false
555+
const pending = handler(
553556
new Request("https://test.example.com/api", {
554557
method: "POST",
555558
headers: { "Content-Type": "application/json" },
556559
body: JSON.stringify({ query: "test" }),
557560
}),
558-
)
561+
).then(res => {
562+
settled = true
563+
return res
564+
})
565+
566+
// Let all of the handler's own awaits drain. The report promise is still
567+
// pending, so the handler must not have resolved.
568+
await new Promise(resolve => setTimeout(resolve, 0))
569+
expect(mockReporter).toHaveBeenCalledOnce()
570+
expect(settled).toBe(false)
571+
572+
releaseReport()
573+
const response = await pending
574+
expect(settled).toBe(true)
559575
expect(response.status).toBe(200)
560-
expect(onInvocation).toHaveBeenCalledOnce()
561-
// onInvocation runs before usageReporting (which is fire-and-forget)
562-
expect(reporterCalledBeforeReturn).toBe(false)
563-
await vi.waitFor(() => expect(mockReporter).toHaveBeenCalledOnce())
564576
})
565577

566578
it("logs usageReporting errors without affecting the response", async () => {

src/lib/handler/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,20 @@ export function createToolHandler<TIn, TOut>(
147147
}
148148
}
149149

150-
const response = Response.json(outputResult.data, { status: 200 })
151-
150+
// Await the report so it completes before the response is returned.
151+
// On serverless runtimes (Vercel/AWS Lambda) the function is frozen
152+
// once the response flushes, which kills any fire-and-forget request
153+
// still in flight — the report would silently never arrive. The
154+
// reporter has its own AbortController timeout (default 5s) and never
155+
// throws; the catch is belt-and-suspenders so a misbehaving reporter
156+
// can never turn a successful tool call into a failure.
152157
if (usageReporter) {
153-
void usageReporter(event).catch((err) => {
158+
await usageReporter(event).catch((err) => {
154159
console.error("[tool-sdk] usageReporting failed:", err)
155160
})
156161
}
157162

158-
return response
163+
return Response.json(outputResult.data, { status: 200 })
159164
} catch (error) {
160165
if (error instanceof ToolHandlerError) {
161166
console.error("[tool-sdk] tool handler error:", error)

0 commit comments

Comments
 (0)