diff --git a/.agent/critical-audit/2026-09-02T05-15-03Z/findings.jsonl b/.agent/critical-audit/2026-09-02T05-15-03Z/findings.jsonl new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.agent/critical-audit/2026-09-02T05-15-03Z/findings.jsonl @@ -0,0 +1 @@ + diff --git a/.agent/critical-audit/2026-09-02T05-15-03Z/manifest.json b/.agent/critical-audit/2026-09-02T05-15-03Z/manifest.json new file mode 100644 index 0000000..fd4ed10 --- /dev/null +++ b/.agent/critical-audit/2026-09-02T05-15-03Z/manifest.json @@ -0,0 +1,11 @@ +{ + "skill": "critical-audit", + "scope": "origin/main..ccecafc", + "base": "6d95dc09b3c68d2d67a6550d829c253c7552efb7", + "head": "ccecafcf06fe6950dcfbfca5ca9d910a4e294d4f", + "files": 5, + "projectType": "TypeScript package", + "reviewers": ["correctness-security", "architecture-quality", "standards-real-system"], + "mode": "serial", + "verdict": "APPROVE" +} diff --git a/.agent/critical-audit/2026-09-02T05-15-03Z/summary.md b/.agent/critical-audit/2026-09-02T05-15-03Z/summary.md new file mode 100644 index 0000000..dd74cb0 --- /dev/null +++ b/.agent/critical-audit/2026-09-02T05-15-03Z/summary.md @@ -0,0 +1,35 @@ +# Audit: async input-token bounds — 6d95dc0..ccecafc — n=5 files, 0 findings + +**Verdict:** APPROVE — no reproducible correctness or compatibility defect · 0 CRITICAL / 0 HIGH / 0 MEDIUM / 0 LOW +**Worst:** none · cost if shipped unmeasured because no finding remains +**Next:** `/verify` with the full suite and build + +## Scope + +| Field | Value | +|---|---| +| Files | n=5 via `git diff --name-only origin/main..HEAD` | +| Base..head | `6d95dc09b3c68d2d67a6550d829c253c7552efb7..ccecafcf06fe6950dcfbfca5ca9d910a4e294d4f` | +| Project type | TypeScript package | +| Reviewers | A,B,C · serial | +| Not inspected | Live consumer deployments and external payment providers | + +## Findings — 0 of 0, ranked + +| # | Sev | file:line | Defect | Failure scenario (input/state → wrong result) | Status | Evidence | Fix | Verification | Cost if shipped | Saved if fixed | +|---:|---|---|---|---|---|---|---|---|---:|---:| +| — | — | — | — | — | — | — | — | — | 0 | 0 | + +0 dropped of 0 reviewed findings. + +## Assumptions & unverified + +| Assumption | Finding it would flip | Check that settles it | +|---|---|---| +| Consumers provide a bound that covers hidden provider input. | Underpayment from an undersized host bound | Provider-side usage receipt integration test | +| Thread authorization remains the host's responsibility after the pre-payment bound callback. | Unauthorized retained-history access | Run the host's authorization and history-read integration test | + +## Self-gate + +9/9 passed — failed: none. +1 verdict = decision + 1 number · 2 every finding has file:line · 3 concrete failure scenario · 4 status label · 5 evidence pointer · 6 cost both sides · 7 fix and verification · 8 zero unsupported adjectives · 9 words ≤600 outside tables. diff --git a/.agent/skill-runs.jsonl b/.agent/skill-runs.jsonl index 3beb77c..f7ee4d0 100644 --- a/.agent/skill-runs.jsonl +++ b/.agent/skill-runs.jsonl @@ -9,3 +9,4 @@ {"skill":"/release-conductor","ts":"2026-09-01T18:17:35Z","project":"agent-app-gateway-structured-failure","target":"@tangle-network/agent-gateway v0.8.10 trusted npm release","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} {"skill":"/critical-audit","ts":"2026-09-02T02:58:52Z","project":"agent-gateway-policy-20260902","target":"origin/main..ad9c58f n=12 files","operatorPrompt":"","durationMin":null,"verdict":"REQUEST_CHANGES","dispatchedTo":"/review-to-green","operatorOverride":null,"transcriptPath":null,"traceDir":null} {"skill":"/critical-audit","ts":"2026-09-02T03:00:24Z","project":"agent-gateway-policy-20260902","target":"PR #35 re-audit n=12 files","operatorPrompt":"","durationMin":null,"verdict":"APPROVE","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/critical-audit","ts":"2026-09-02T05:15:42Z","project":"agent-gateway-input-bound-20260902","target":"async input-token bounds n=5 files","operatorPrompt":"","durationMin":null,"verdict":"APPROVE","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null} diff --git a/README.md b/README.md index 89c3d11..a6c0443 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ Keep version 1 explicitly configured while old and new gateways coexist; shared Before it calls the verifier, the gateway requires the signed amount to cover the complete filtered conversation plus the requested output limit. The default bound includes system text, message roles, and JSON framing. Set `inputTokenBound` when the provider adds harness, tool, workspace, or other hidden context. +The callback can read retained history asynchronously through its `threadId`. +It runs before payment verification and receives no consumer identity. The gateway rejects `max_tokens` above `maxOutputTokens` and stops the sandbox stream at the accepted limit. An unpaid request receives `required_amount`, `currency_decimals`, and `max_output_tokens` in the 402 response. Sandbox adapters should emit a complete `sandbox.usage` receipt. diff --git a/src/dispatch-authorization.ts b/src/dispatch-authorization.ts index 4a7a0ca..b357190 100644 --- a/src/dispatch-authorization.ts +++ b/src/dispatch-authorization.ts @@ -140,7 +140,12 @@ export async function authenticateAndGuard( if (config.inputTokenBound) { let configuredBound: number try { - configuredBound = config.inputTokenBound({ agent, messages: filtered }) + configuredBound = await config.inputTokenBound({ + agent, + messages: filtered, + requestId, + ...(threadId ? { threadId } : {}), + }) } catch { return c.json( { diff --git a/src/index.ts b/src/index.ts index ca16868..339e8bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { createAgentGateway } from './middleware' -export { reclaimPayment, SandboxStreamError } from './dispatch' +export { maximumBillableInputTokens, reclaimPayment, SandboxStreamError } from './dispatch' export { recoverPayment, recoverPayments, diff --git a/src/types.ts b/src/types.ts index 9537c8a..5228e8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -360,11 +360,16 @@ export interface GatewayConfig { /** * Return a safe upper bound for the complete provider input. * Include system, chat framing, retained history, tools, harness, and workspace context. + * The callback runs before payment verification, so it receives no consumer + * identity. `threadId` is sufficient for a host to read its retained history. */ inputTokenBound?: (input: { agent: AgentMeta messages: ChatMessage[] - }) => number + requestId: string + /** Stable UI conversation id when `conversationMode` is `thread`. */ + threadId?: string + }) => number | Promise /** Hidden provider spend limits included in the pre-execution payment quote. */ executionBudget?: { diff --git a/tests/pr11-regressions.test.ts b/tests/pr11-regressions.test.ts index 180db46..5480716 100644 --- a/tests/pr11-regressions.test.ts +++ b/tests/pr11-regressions.test.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import { afterEach, describe, expect, it, vi } from 'vitest' +import { maximumBillableInputTokens as rootMaximumBillableInputTokens } from '../src' import { InMemoryTaskStore, type TaskStore } from '../src/a2a/task-store' import { SqlTaskStore, type SqlAdapter } from '../src/a2a/task-store-sql' import { InMemoryPushNotificationStore } from '../src/a2a/push-notifications' @@ -91,6 +92,10 @@ function durableConfig( } describe('PR #11 production regressions', () => { + it('exports the conservative input bound from the package root', () => { + expect(rootMaximumBillableInputTokens({ ...agent, systemPrompt: '' }, '😀')).toBe(4) + }) + it('claims one terminal webhook when cancellation races fenced settlement on two workers', async () => { const taskStore = new ServerAssignedTaskStore( new InMemoryTaskStore(), @@ -875,12 +880,26 @@ describe('PR #11 production regressions', () => { it('uses the configured complete provider input bound before quoting', async () => { let quotedMessages: Array<{ role: string; content: string }> | undefined - const inputTokenBound = ({ messages }: { messages: Array<{ role: string; content: string }> }) => { + let quotedThreadId: string | undefined + let quotedRequestId: string | undefined + const inputTokenBound = async ({ + messages, + threadId, + requestId, + }: { + messages: Array<{ role: string; content: string }> + threadId?: string + requestId: string + }) => { + await Promise.resolve() quotedMessages = messages + quotedThreadId = threadId + quotedRequestId = requestId return 4_096 } const app = new Hono() app.route('/v1/agents', createAgentGateway(durableConfig({ + conversationMode: 'thread', maxOutputTokens: 1_024, defaultOutputTokens: 1_024, inputTokenBound, @@ -888,7 +907,10 @@ describe('PR #11 production regressions', () => { const response = await app.request('/v1/agents/pr11/chat/completions', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Tangle-Thread-Id': 'thread-existing-1', + }, body: JSON.stringify({ messages: [ { role: 'user', content: 'first turn' }, @@ -907,6 +929,8 @@ describe('PR #11 production regressions', () => { { role: 'assistant', content: 'prior answer' }, { role: 'user', content: 'current turn' }, ]) + expect(quotedThreadId).toBe('thread-existing-1') + expect(quotedRequestId).toMatch(/^req_[0-9a-f]{32}$/) expect(body.error?.x402?.required_amount).toBe( requiredX402Amount(agent.pricePerTokenUsd, 4_096, 1_024, 6, 1_024, 1_024) .toString(),