Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agent/critical-audit/2026-09-02T05-15-03Z/findings.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

11 changes: 11 additions & 0 deletions .agent/critical-audit/2026-09-02T05-15-03Z/manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
35 changes: 35 additions & 0 deletions .agent/critical-audit/2026-09-02T05-15-03Z/summary.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .agent/skill-runs.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion src/dispatch-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { createAgentGateway } from './middleware'
export { reclaimPayment, SandboxStreamError } from './dispatch'
export { maximumBillableInputTokens, reclaimPayment, SandboxStreamError } from './dispatch'
export {
recoverPayment,
recoverPayments,
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>

/** Hidden provider spend limits included in the pre-execution payment quote. */
executionBudget?: {
Expand Down
28 changes: 26 additions & 2 deletions tests/pr11-regressions.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -875,20 +880,37 @@ 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,
})))

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' },
Expand All @@ -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(),
Expand Down