@bnbagent/studio-cli 0.0.13-alpha.6 → 0.0.13-alpha.8
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/dist/bag.js +354 -72
- package/dist/{chunk-LEBIBQWN.js → chunk-NTDWVEW2.js} +59 -17
- package/dist/{deployCli-H65YVV3V.js → deployCli-ZESBUWQB.js} +3 -1
- package/package.json +4 -3
- package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +2 -2
- package/recipes/agent/recipe.toml +1 -1
- package/recipes/mpp-buyer/recipe.toml +1 -1
- package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +2 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +14 -6
- package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +63 -30
- package/recipes/runtimes/agentcore/code/{{PKG}}/requestLimits.ts.tmpl +178 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +3 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/unifiedMain.ts.tmpl +2 -0
- package/recipes/runtimes/agentcore/recipe.toml +1 -1
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +14 -6
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +63 -30
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/requestLimits.ts.tmpl +178 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/sellerCore.ts.tmpl +3 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/unifiedMain.ts.tmpl +2 -0
- package/recipes/runtimes/azure-foundry/recipe.toml +1 -1
- package/recipes/wallet/recipe.toml +0 -1
- package/recipes/x402-buyer/recipe.toml +1 -1
- package/skills/references/bnbagent-studio-adding-to-project.md +1 -1
- package/skills/references/bnbagent-studio-operating.md +1 -1
- package/skills/references/bnbagent-studio-selling-via-b402.md +3 -3
- package/skills/references/bnbagent-studio-using-altana-wallet.md +3 -2
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
|
|
41
41
|
import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
|
|
42
42
|
import { getWallet } from "@bnbagent/studio-runtime/wallet";
|
|
43
|
+
import { limitCommerceOperation } from "./requestLimits.js";
|
|
43
44
|
import * as defaultSigning from "./signing.js";
|
|
44
45
|
|
|
45
46
|
const log = {
|
|
@@ -227,6 +228,7 @@ export class SellerCore {
|
|
|
227
228
|
data: Record<string, unknown>,
|
|
228
229
|
): Promise<Record<string, unknown>> {
|
|
229
230
|
this.requireCommerceRail();
|
|
231
|
+
await limitCommerceOperation("negotiate");
|
|
230
232
|
let request = data.request;
|
|
231
233
|
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
232
234
|
const picked: Record<string, unknown> = {};
|
|
@@ -262,6 +264,7 @@ export class SellerCore {
|
|
|
262
264
|
data: Record<string, unknown>,
|
|
263
265
|
): Promise<Record<string, unknown>> {
|
|
264
266
|
this.requireCommerceRail();
|
|
267
|
+
await limitCommerceOperation("notify_funded");
|
|
265
268
|
const raw = data.job_id;
|
|
266
269
|
if (raw === undefined || raw === null || String(raw) === "") {
|
|
267
270
|
this.spawn(() => this.sweep()); // bare notify → just scan stragglers
|
|
@@ -95,6 +95,7 @@ import express from "express";
|
|
|
95
95
|
import { buildAgentCard } from "./agentCard.js";
|
|
96
96
|
import { SellerAgentExecutor } from "./executor.js";
|
|
97
97
|
import { buildModel } from "./model.js";
|
|
98
|
+
import { requestLimitContext } from "./requestLimits.js";
|
|
98
99
|
import type { RunWork } from "./sellerCore.js";
|
|
99
100
|
import { LLM_READ_TOOLS } from "./tools.js";
|
|
100
101
|
|
|
@@ -500,6 +501,7 @@ async function main(): Promise<void> {
|
|
|
500
501
|
);
|
|
501
502
|
|
|
502
503
|
const app = express();
|
|
504
|
+
app.use(requestLimitContext);
|
|
503
505
|
|
|
504
506
|
// GET /ping status fed to AgentCore: HEALTHY_BUSY while a background
|
|
505
507
|
// delivery is in flight, else HEALTHY.
|
|
@@ -32,7 +32,7 @@ node = [
|
|
|
32
32
|
# BNBAGENT_RUNTIME_SECRET_ID is set (default secretsmanager mode + platform).
|
|
33
33
|
"@aws-sdk/client-secrets-manager@^3.600.0",
|
|
34
34
|
"@bnbagent/studio-runtime",
|
|
35
|
-
"@bnbagent/sdk@0.5.
|
|
35
|
+
"@bnbagent/sdk@0.5.5",
|
|
36
36
|
# The LLM work hook (generateText + tools) and the model factory.
|
|
37
37
|
"ai@^7.0.29",
|
|
38
38
|
# Tool input schemas (AI SDK tools + MCP registerTool).
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type ExecutionEventBus,
|
|
44
44
|
type RequestContext,
|
|
45
45
|
} from "@a2a-js/sdk/server";
|
|
46
|
+
import { isCommerceRateLimitError } from "./requestLimits.js";
|
|
46
47
|
import { SellerCore } from "./sellerCore.js";
|
|
47
48
|
|
|
48
49
|
const log = {
|
|
@@ -94,9 +95,10 @@ export class SellerAgentExecutor extends SellerCore implements AgentExecutor {
|
|
|
94
95
|
} catch (e) {
|
|
95
96
|
// a skill failure must still ACK the buyer
|
|
96
97
|
log.error(`skill ${JSON.stringify(skill)} failed`, e);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
if (isCommerceRateLimitError(e)) {
|
|
99
|
+
return { status: "retry", error: "seller rate limit exceeded", skill };
|
|
100
|
+
}
|
|
101
|
+
return { error: "seller operation failed; retry later", skill };
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -135,9 +137,15 @@ export class SellerAgentExecutor extends SellerCore implements AgentExecutor {
|
|
|
135
137
|
// returned as a result above (peer of the MCP runtime: faults →
|
|
136
138
|
// isError, business outcomes → result).
|
|
137
139
|
log.error(`skill ${JSON.stringify(skill)} failed`, e);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
140
|
+
if (isCommerceRateLimitError(e)) {
|
|
141
|
+
result = {
|
|
142
|
+
status: "retry",
|
|
143
|
+
error: "seller rate limit exceeded",
|
|
144
|
+
skill,
|
|
145
|
+
};
|
|
146
|
+
} else {
|
|
147
|
+
throw A2AError.internalError("seller operation failed; retry later");
|
|
148
|
+
}
|
|
141
149
|
}
|
|
142
150
|
reply(eventBus, context, result);
|
|
143
151
|
};
|
|
@@ -79,6 +79,11 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
79
79
|
import { generateText, stepCountIs } from "ai";
|
|
80
80
|
import express from "express";
|
|
81
81
|
import { z } from "zod";
|
|
82
|
+
import {
|
|
83
|
+
isCommerceRateLimitError,
|
|
84
|
+
limitCommerceOperation,
|
|
85
|
+
requestLimitContext,
|
|
86
|
+
} from "./requestLimits.js";
|
|
82
87
|
import * as signing from "./signing.js";
|
|
83
88
|
|
|
84
89
|
const APP_NAME = "agent";
|
|
@@ -88,6 +93,11 @@ const log = {
|
|
|
88
93
|
console.error(`[seller-agent.mcp] ERROR ${msg}`, e ?? ""),
|
|
89
94
|
};
|
|
90
95
|
|
|
96
|
+
function protocolFailure(scope: string, error: unknown): never {
|
|
97
|
+
log.error(scope, error);
|
|
98
|
+
throw new Error("seller operation failed; retry later");
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
// ── Runtime secrets ───────────────────────────────────────────────────────────
|
|
92
102
|
// Keep plaintext secrets OUT of agentcore.json. When BNBAGENT_RUNTIME_SECRET_ID
|
|
93
103
|
// is set (deployed runtime), pull a JSON {ENV_NAME: value} blob from AWS
|
|
@@ -296,16 +306,24 @@ export function buildMcpServer(
|
|
|
296
306
|
annotations: COMMERCE_ANNOTATIONS,
|
|
297
307
|
},
|
|
298
308
|
// Error contract (unified with the A2A executor): an unexpected fault
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// `A2AError.internalError` → JSON-RPC -32603. Only CLASSIFIED business
|
|
303
|
-
// outcomes are returned as a normal result. So do NOT wrap this in a
|
|
304
|
-
// try/catch that masks a fault as a successful quote.
|
|
309
|
+
// becomes an MCP `isError` result with a generic public message; its full
|
|
310
|
+
// detail is logged server-side. Classified quota exhaustion is returned
|
|
311
|
+
// as a normal retry result and never as a fake quote.
|
|
305
312
|
async ({ task_description, terms }) => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
313
|
+
try {
|
|
314
|
+
await limitCommerceOperation("negotiate");
|
|
315
|
+
const request = { task_description, terms: terms ?? {} };
|
|
316
|
+
const clamped = signing.clampPrice(signing.listPrice());
|
|
317
|
+
return toolResult(await signing.signQuote(request, clamped));
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if (isCommerceRateLimitError(e)) {
|
|
320
|
+
return toolResult({
|
|
321
|
+
status: "retry",
|
|
322
|
+
reason: "seller rate limit exceeded",
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return protocolFailure("negotiate failed", e);
|
|
326
|
+
}
|
|
309
327
|
},
|
|
310
328
|
);
|
|
311
329
|
|
|
@@ -327,6 +345,17 @@ export function buildMcpServer(
|
|
|
327
345
|
annotations: COMMERCE_ANNOTATIONS,
|
|
328
346
|
},
|
|
329
347
|
async ({ job_id }, extra) => {
|
|
348
|
+
try {
|
|
349
|
+
await limitCommerceOperation("notify_funded");
|
|
350
|
+
} catch (e) {
|
|
351
|
+
if (isCommerceRateLimitError(e)) {
|
|
352
|
+
return toolResult({
|
|
353
|
+
status: "retry",
|
|
354
|
+
reason: "seller rate limit exceeded",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return protocolFailure("notify_funded limiter failed", e);
|
|
358
|
+
}
|
|
330
359
|
let jid: number;
|
|
331
360
|
try {
|
|
332
361
|
jid = parseJobId(job_id);
|
|
@@ -348,40 +377,43 @@ export function buildMcpServer(
|
|
|
348
377
|
} catch (e) {
|
|
349
378
|
// a failed verify is transient; tell the buyer to retry
|
|
350
379
|
log.error(`verify of job ${jid} failed`, e);
|
|
351
|
-
const name = e instanceof Error ? e.constructor.name : "Error";
|
|
352
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
353
380
|
return toolResult({
|
|
354
381
|
status: "retry",
|
|
355
382
|
job_id: jid,
|
|
356
|
-
reason:
|
|
383
|
+
reason: "chain verification temporarily unavailable",
|
|
357
384
|
});
|
|
358
385
|
}
|
|
359
386
|
if (!verdict.ok) {
|
|
360
387
|
return toolResult({
|
|
361
388
|
status: verdict.permanent ? "rejected" : "retry",
|
|
362
389
|
job_id: jid,
|
|
363
|
-
reason: verdict.
|
|
390
|
+
reason: verdict.permanent
|
|
391
|
+
? verdict.reason
|
|
392
|
+
: "chain verification temporarily unavailable",
|
|
364
393
|
});
|
|
365
394
|
}
|
|
366
395
|
|
|
367
396
|
// 2/4 — produce the deliverable (THE ONLY LLM CALL; specialise the
|
|
368
397
|
// prompt here)
|
|
369
398
|
await reportProgress(extra, 2, 4);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
spec
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
399
|
+
let work: string;
|
|
400
|
+
try {
|
|
401
|
+
const spec = await signing.jobSpec(jid);
|
|
402
|
+
const task =
|
|
403
|
+
spec !== null
|
|
404
|
+
? JSON.stringify({ task: spec.task, terms: spec.terms })
|
|
405
|
+
: `job ${jid}`;
|
|
406
|
+
const prompt =
|
|
407
|
+
"You accepted and were paid for the following job. Produce the deliverable " +
|
|
408
|
+
`now. Be complete and self-contained.\n\nJOB CONTEXT:\n${task}`;
|
|
409
|
+
work = await runLlm(prompt);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
return protocolFailure(`delivery preparation for job ${jid} failed`, e);
|
|
412
|
+
}
|
|
413
|
+
// Unexpected LLM/RPC faults are logged in full, then surfaced through
|
|
414
|
+
// MCP's isError channel with a generic public message. Only the
|
|
415
|
+
// deterministic SubmitPermanentlyUnsupportedError is a classified
|
|
416
|
+
// "rejected" business result.
|
|
385
417
|
// 3/4 — sign + broadcast the on-chain submit (re-verifies FUNDED inside)
|
|
386
418
|
await reportProgress(extra, 3, 4);
|
|
387
419
|
let res: { submitTx: string; deliverableUrl: string | null };
|
|
@@ -401,10 +433,10 @@ export function buildMcpServer(
|
|
|
401
433
|
status: "rejected",
|
|
402
434
|
job_id: jid,
|
|
403
435
|
skip: true,
|
|
404
|
-
reason:
|
|
436
|
+
reason: "seller wallet does not support result submission",
|
|
405
437
|
});
|
|
406
438
|
}
|
|
407
|
-
|
|
439
|
+
return protocolFailure(`submit of job ${jid} failed`, e);
|
|
408
440
|
}
|
|
409
441
|
|
|
410
442
|
// 4/4 — done
|
|
@@ -606,6 +638,7 @@ async function main(): Promise<void> {
|
|
|
606
638
|
});
|
|
607
639
|
|
|
608
640
|
const app = express();
|
|
641
|
+
app.use(requestLimitContext);
|
|
609
642
|
|
|
610
643
|
if (seller.state !== "disabled") {
|
|
611
644
|
app.all(
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-level quotas for the two public seller operations.
|
|
3
|
+
*
|
|
4
|
+
* A process-wide bucket is always enforced, including when the hosting
|
|
5
|
+
* platform exposes no trustworthy caller identity. A second per-caller
|
|
6
|
+
* bucket is enabled only when the operator names a header that its trusted
|
|
7
|
+
* edge sets after stripping caller-supplied values. Request payload fields
|
|
8
|
+
* and forwarded IP headers are deliberately never treated as identities.
|
|
9
|
+
* The defaults are process-local; multi-replica owners can inject async
|
|
10
|
+
* shared limiters without making that infrastructure mandatory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
import { RateLimitExceeded, SlidingWindowLimiter } from "@bnbagent/sdk/utils";
|
|
15
|
+
import type { NextFunction, Request, Response } from "express";
|
|
16
|
+
|
|
17
|
+
type CommerceOperation = "negotiate" | "notify_funded";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_GLOBAL_MAX = 120;
|
|
20
|
+
const DEFAULT_CALLER_MAX = 20;
|
|
21
|
+
const DEFAULT_WINDOW_SECONDS = 60;
|
|
22
|
+
const DEFAULT_MAX_CALLERS = 10_000;
|
|
23
|
+
const SHARED_LIMITER_TIMEOUT_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
export interface CommerceRateLimiter {
|
|
26
|
+
/** Consume one request; honor cancellation and reject denied requests. */
|
|
27
|
+
check(key: string, signal?: AbortSignal): void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommerceRateLimiters {
|
|
31
|
+
readonly global: CommerceRateLimiter;
|
|
32
|
+
readonly caller: CommerceRateLimiter;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CachedLimiters extends CommerceRateLimiters {
|
|
36
|
+
readonly configKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const callerContext = new AsyncLocalStorage<string | undefined>();
|
|
40
|
+
let cached: CachedLimiters | undefined;
|
|
41
|
+
let injected: CommerceRateLimiters | undefined;
|
|
42
|
+
let warnedProcessLocal = false;
|
|
43
|
+
|
|
44
|
+
/** Replace process-local counters with application-owned shared limiters. */
|
|
45
|
+
export function setCommerceRateLimiters(
|
|
46
|
+
value: CommerceRateLimiters | null,
|
|
47
|
+
): void {
|
|
48
|
+
injected = value ?? undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positiveEnv(name: string, fallback: number): number {
|
|
52
|
+
const raw = process.env[name];
|
|
53
|
+
if (raw === undefined || !/^\d+$/u.test(raw)) return fallback;
|
|
54
|
+
const value = Number(raw);
|
|
55
|
+
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function limiters(): CommerceRateLimiters {
|
|
59
|
+
if (injected) return injected;
|
|
60
|
+
const environment = (
|
|
61
|
+
process.env.ENV ||
|
|
62
|
+
process.env.ENVIRONMENT ||
|
|
63
|
+
process.env.NODE_ENV ||
|
|
64
|
+
""
|
|
65
|
+
)
|
|
66
|
+
.trim()
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
if (
|
|
69
|
+
!warnedProcessLocal &&
|
|
70
|
+
!["dev", "development", "test"].includes(environment)
|
|
71
|
+
) {
|
|
72
|
+
warnedProcessLocal = true;
|
|
73
|
+
console.warn(
|
|
74
|
+
"[seller-agent] this process is using process-local rate limits; " +
|
|
75
|
+
"inject shared limiters or enforce equivalent limits at a trusted edge before scaling out.",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const names = [
|
|
79
|
+
"SELLER_RATE_LIMIT_GLOBAL_MAX_REQUESTS",
|
|
80
|
+
"SELLER_RATE_LIMIT_CALLER_MAX_REQUESTS",
|
|
81
|
+
"SELLER_RATE_LIMIT_WINDOW_SECONDS",
|
|
82
|
+
"SELLER_RATE_LIMIT_MAX_CALLERS",
|
|
83
|
+
] as const;
|
|
84
|
+
const configKey = names.map((name) => process.env[name] ?? "").join("\0");
|
|
85
|
+
if (cached?.configKey === configKey) return cached;
|
|
86
|
+
|
|
87
|
+
const windowSeconds = positiveEnv(
|
|
88
|
+
"SELLER_RATE_LIMIT_WINDOW_SECONDS",
|
|
89
|
+
DEFAULT_WINDOW_SECONDS,
|
|
90
|
+
);
|
|
91
|
+
cached = {
|
|
92
|
+
configKey,
|
|
93
|
+
global: new SlidingWindowLimiter(
|
|
94
|
+
positiveEnv("SELLER_RATE_LIMIT_GLOBAL_MAX_REQUESTS", DEFAULT_GLOBAL_MAX),
|
|
95
|
+
windowSeconds,
|
|
96
|
+
2,
|
|
97
|
+
),
|
|
98
|
+
caller: new SlidingWindowLimiter(
|
|
99
|
+
positiveEnv("SELLER_RATE_LIMIT_CALLER_MAX_REQUESTS", DEFAULT_CALLER_MAX),
|
|
100
|
+
windowSeconds,
|
|
101
|
+
positiveEnv("SELLER_RATE_LIMIT_MAX_CALLERS", DEFAULT_MAX_CALLERS),
|
|
102
|
+
),
|
|
103
|
+
};
|
|
104
|
+
return cached;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function checkLimiter(
|
|
108
|
+
limiter: CommerceRateLimiter,
|
|
109
|
+
key: string,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
try {
|
|
114
|
+
await Promise.race([
|
|
115
|
+
Promise.resolve(limiter.check(key, controller.signal)),
|
|
116
|
+
new Promise<never>((_resolve, reject) => {
|
|
117
|
+
timer = setTimeout(
|
|
118
|
+
() => {
|
|
119
|
+
controller.abort();
|
|
120
|
+
reject(new RateLimitExceeded("Seller rate limiter unavailable"));
|
|
121
|
+
},
|
|
122
|
+
SHARED_LIMITER_TIMEOUT_MS,
|
|
123
|
+
);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
127
|
+
} finally {
|
|
128
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function trustedCaller(headers: Request["headers"]): string | undefined {
|
|
133
|
+
const header = (process.env.SELLER_TRUSTED_CALLER_HEADER ?? "")
|
|
134
|
+
.trim()
|
|
135
|
+
.toLowerCase();
|
|
136
|
+
if (!/^[a-z0-9-]+$/u.test(header)) return undefined;
|
|
137
|
+
|
|
138
|
+
const raw = headers[header];
|
|
139
|
+
if (typeof raw !== "string") return undefined;
|
|
140
|
+
const value = raw.trim();
|
|
141
|
+
if (value.length === 0 || value.length > 256 || /[\r\n]/u.test(value)) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Carry only an operator-configured, edge-authenticated identity. */
|
|
148
|
+
export function withTrustedCaller<T>(
|
|
149
|
+
headers: Request["headers"],
|
|
150
|
+
work: () => T,
|
|
151
|
+
): T {
|
|
152
|
+
return callerContext.run(trustedCaller(headers), work);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Express middleware that makes the trusted identity available to handlers. */
|
|
156
|
+
export function requestLimitContext(
|
|
157
|
+
req: Request,
|
|
158
|
+
_res: Response,
|
|
159
|
+
next: NextFunction,
|
|
160
|
+
): void {
|
|
161
|
+
withTrustedCaller(req.headers, next);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Consume both the mandatory process bucket and optional caller bucket. */
|
|
165
|
+
export async function limitCommerceOperation(
|
|
166
|
+
operation: CommerceOperation,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const active = limiters();
|
|
169
|
+
await checkLimiter(active.global, operation);
|
|
170
|
+
const caller = callerContext.getStore();
|
|
171
|
+
if (caller !== undefined) {
|
|
172
|
+
await checkLimiter(active.caller, `${operation}:${caller}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function isCommerceRateLimitError(error: unknown): boolean {
|
|
177
|
+
return error instanceof RateLimitExceeded;
|
|
178
|
+
}
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
|
|
41
41
|
import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
|
|
42
42
|
import { getWallet } from "@bnbagent/studio-runtime/wallet";
|
|
43
|
+
import { limitCommerceOperation } from "./requestLimits.js";
|
|
43
44
|
import * as defaultSigning from "./signing.js";
|
|
44
45
|
|
|
45
46
|
const log = {
|
|
@@ -227,6 +228,7 @@ export class SellerCore {
|
|
|
227
228
|
data: Record<string, unknown>,
|
|
228
229
|
): Promise<Record<string, unknown>> {
|
|
229
230
|
this.requireCommerceRail();
|
|
231
|
+
await limitCommerceOperation("negotiate");
|
|
230
232
|
let request = data.request;
|
|
231
233
|
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
232
234
|
const picked: Record<string, unknown> = {};
|
|
@@ -262,6 +264,7 @@ export class SellerCore {
|
|
|
262
264
|
data: Record<string, unknown>,
|
|
263
265
|
): Promise<Record<string, unknown>> {
|
|
264
266
|
this.requireCommerceRail();
|
|
267
|
+
await limitCommerceOperation("notify_funded");
|
|
265
268
|
const raw = data.job_id;
|
|
266
269
|
if (raw === undefined || raw === null || String(raw) === "") {
|
|
267
270
|
this.spawn(() => this.sweep()); // bare notify → just scan stragglers
|
|
@@ -95,6 +95,7 @@ import express from "express";
|
|
|
95
95
|
import { buildAgentCard } from "./agentCard.js";
|
|
96
96
|
import { SellerAgentExecutor } from "./executor.js";
|
|
97
97
|
import { buildModel } from "./model.js";
|
|
98
|
+
import { requestLimitContext } from "./requestLimits.js";
|
|
98
99
|
import type { RunWork } from "./sellerCore.js";
|
|
99
100
|
import { LLM_READ_TOOLS } from "./tools.js";
|
|
100
101
|
|
|
@@ -500,6 +501,7 @@ async function main(): Promise<void> {
|
|
|
500
501
|
);
|
|
501
502
|
|
|
502
503
|
const app = express();
|
|
504
|
+
app.use(requestLimitContext);
|
|
503
505
|
|
|
504
506
|
// GET /ping status fed to AgentCore: HEALTHY_BUSY while a background
|
|
505
507
|
// delivery is in flight, else HEALTHY.
|
|
@@ -29,7 +29,7 @@ node = [
|
|
|
29
29
|
# on either cloud.
|
|
30
30
|
"@aws-sdk/client-secrets-manager@^3.600.0",
|
|
31
31
|
"@bnbagent/studio-runtime",
|
|
32
|
-
"@bnbagent/sdk@0.5.
|
|
32
|
+
"@bnbagent/sdk@0.5.5",
|
|
33
33
|
# The LLM work hook (generateText + tools) and the model factory
|
|
34
34
|
# (model.ts buildModel — studio.toml [llm] + the provider key env).
|
|
35
35
|
"ai@^7.0.29",
|
|
@@ -74,7 +74,7 @@ Tune the price in `app/agent/studio.toml` (`[payments.erc8183]` `min_price`/ `ma
|
|
|
74
74
|
|
|
75
75
|
Use `bag config set payments.erc8183.price 0` only for an explicit FREE product decision. Studio stores ERC-8183 amounts as decimal strings and reports FREE in `bag doctor`; the canonical stack supports zero funding. If a custom deployment is selected, set all three `ERC8183_*_ADDRESS` overrides from that same stack. The buyer still runs `setBudget(0)` and `fund(0)`, but no ERC-20 approval or token escrow occurs. Require `bag doctor` and `bag deploy prepare` to pass.
|
|
76
76
|
|
|
77
|
-
For an X402 face, choose its request price independently. Use `bag config set payments.
|
|
77
|
+
For an X402 face, choose its request price independently. Use `bag config set payments.b402_seller.price_usd 0` only when the existing agent is intentionally becoming an unrestricted anonymous FREE API. This path bypasses B402 verify/settle, payment, and settlement audit; it needs no merchant credentials and Studio will not synchronize any configured B402 secrets. Positive prices retain the paid B402 onboarding and settle-before-work flow. Verify the choice with `bag x402 sell status`, `bag doctor`, and `bag deploy prepare`.
|
|
78
78
|
|
|
79
79
|
## Step 4c - LLM credit continuity (automatic, NOT an LLM tool)
|
|
80
80
|
|
|
@@ -164,7 +164,7 @@ JobStatus enum: `OPEN` (0) → `FUNDED` (1) → `SUBMITTED` (2) → `COMPLETED`
|
|
|
164
164
|
| Job stays `FUNDED`, never reaches `SUBMITTED` after an `accepted` ack | Background delivery failed (`runWork` / `submitResult` raised) - **not** visible in the A2A reply | The ack only confirms verify passed; delivery runs in the background. Observe the failure via the chain (job never leaves `FUNDED`) + CloudWatch logs; a later `notify_funded` re-attempts it via the sweep |
|
|
165
165
|
| `ERC8183JobOps` has no such export from `@bnbagent/sdk` | package.json pinned an old `@bnbagent/sdk` (missing class) | Bump the dependency and reinstall |
|
|
166
166
|
| ERC-8183 contract override is incomplete | Only one or two of commerce/router/policy were selected | Set or remove all three together; never mix stacks |
|
|
167
|
-
| `/x402` is public without a 402 challenge | `payments.
|
|
167
|
+
| `/x402` is public without a 402 challenge | `payments.b402_seller.price_usd = "0"` selected anonymous FREE passthrough | If payment is intended, set a positive decimal price, configure the complete B402 credential set, rerun `bag doctor`, and redeploy |
|
|
168
168
|
| B402 credentials are missing but x402 reports FREE | Expected: FREE bypasses B402 and does not synchronize its secrets | No credential fix is needed; change to a positive price only when the route should charge |
|
|
169
169
|
| `OPENROUTER_API_KEY env var is required` | Loading the entrypoint triggers the emitted `buildModel()` factory | Set the env var even for `bag dev --help` smoke |
|
|
170
170
|
| RPC `limit exceeded` | Public RPC throttle | Retry, or set `STUDIO_BSC_TESTNET_RPC=<private rpc>` |
|
|
@@ -14,7 +14,7 @@ Use this playbook to activate the x402 seller rail for one agent. First choose P
|
|
|
14
14
|
- The agent wallet already exists. In PAID mode its address receives U.
|
|
15
15
|
- The project targets the managed platform or self-hosted AgentCore (azure-foundry cannot activate the rail).
|
|
16
16
|
- PAID managed platform only: an interactive GitHub-login session from `bag platform login` is available for reading the platform Relay egress IPs. A `bnbk_…` CI token does not satisfy this endpoint's GitHub-user check.
|
|
17
|
-
- `[payments.
|
|
17
|
+
- `[payments.b402_seller]` exists. If not, run `bag x402 sell init`.
|
|
18
18
|
|
|
19
19
|
The PAID application uses the **agent wallet address**, not a developer treasury, buyer wallet, or platform wallet.
|
|
20
20
|
|
|
@@ -25,7 +25,7 @@ Use one of these explicit boundaries:
|
|
|
25
25
|
```bash
|
|
26
26
|
bag init <name> --rails b402 --b402-price 0
|
|
27
27
|
bag x402 sell init --price-usd 0
|
|
28
|
-
bag config set payments.
|
|
28
|
+
bag config set payments.b402_seller.price_usd 0
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
`"0"` means anonymous FREE passthrough. The runtime returns work directly and does not issue a 402 challenge, call B402 `/supported`/verify/settle, transfer U, or write an `x402_sell` settlement audit. B402 credentials are ignored and not synchronized. Run `bag x402 sell status`, `bag doctor`, and `bag deploy prepare`; all must label the route FREE.
|
|
@@ -161,7 +161,7 @@ On the managed platform the deploy summary must say `x402 rail is ACTIVE` (or `A
|
|
|
161
161
|
- Never log or print any B402 value or private key.
|
|
162
162
|
- Never put a B402 value in `studio.toml` or a deploy descriptor.
|
|
163
163
|
- Never replay a paid HTTP request whose outcome is unknown. Follow `docs/guides/x402-selling.md` and reconcile `(nonce, network, payer)` first.
|
|
164
|
-
- Binance `/settle` is asynchronous. A parseable `success: false` response with a transaction is pending and requires an idempotent poll with the same settlement payload. Studio
|
|
164
|
+
- Binance `/settle` is asynchronous. A parseable `success: false` response with a transaction is pending and requires an idempotent poll with the same settlement payload. Studio with `@bnb-chain/b402@0.2.1` does not yet perform that poll; it classifies pending as unknown. Version 0.2.1 separately guards credential replays through an atomic store. Do not claim current mainnet readiness until polling is implemented.
|
|
165
165
|
- Settlement happens before work. A later work failure retains the payment and does not trigger an automatic refund.
|
|
166
166
|
- The rail activates on AgentCore and Azure Foundry targets (managed platform or self-hosted); self-hosted targets have no anonymous URL and need an operator-run envelope-v1 front.
|
|
167
167
|
- Every supported `wallet.kind` can be the PAID B402 payout wallet: `evm-local`, `twak`, `turnkey`, and `altana`. The payout lands at the configured `pay_to` or, by default, `[wallet].address`; for altana that address is the admin EOA (EIP-7702 — the smart account address equals the admin address), so the locally-held admin keystore can always move the revenue. Register the B402 merchant credentials for that exact address. FREE x402 bypasses B402 and has no payout.
|
|
@@ -11,6 +11,7 @@ Altana separates trusted administration from runtime authority:
|
|
|
11
11
|
- `.studio/wallets/altana-session.json` is the one bounded, expiring runtime session and must stay mode `0600`.
|
|
12
12
|
- `WALLET_PASSWORD` is admin-only. The Agent gets `ALTANA_SESSION`, never the password or admin keystore.
|
|
13
13
|
- Generic signing is refused. ERC-8183 uses `sessionQuoteSigner()` and the approved quote checker.
|
|
14
|
+
- The generated project pins `@bnbagent/sdk@0.5.5` and `@altananetwork/sdk@0.7.1`; doctor, readiness, and runtime loading reject version drift. SDK 0.5.4 introduced selector-bound calls, removed session-key token approvals, and requires an admin-provisioned bounded Commerce allowance. Projects upgrading from an older SDK must update it, re-grant with `bag wallet session grant --force`, and redeploy.
|
|
14
15
|
- Deployment ships ONLY the serialized session as the `ALTANA_SESSION` runtime secret; the admin keystore and `WALLET_PASSWORD` never leave the operator machine. Renewal after expiry: `bag wallet session grant --force`, then re-run `bag deploy`. Readiness fails on a missing/expired/address-mismatched session, a group/world-readable session file, a session inside the artifact root, or an unresolvable project-local `@altananetwork/sdk`; it warns under 7 days remaining. `bag deploy verify` needs `--skip-register` (no generic signing for the ERC-8004 register).
|
|
15
16
|
- Altana refuses generic message signing, so Pieverse SIWE cannot authenticate `bag llm activate` or runtime credit renewal. `bag init --wallet-kind altana --llm-provider pieverse-llm` is rejected outright; use OpenRouter, OpenAI, or Anthropic (API-key providers). `bag llm activate` and `bag doctor` also flag the combination on projects edited by hand.
|
|
16
17
|
|
|
@@ -34,13 +35,13 @@ bag dev
|
|
|
34
35
|
|
|
35
36
|
Interactive grant recommendations are 10 U/day, 30 days, register=yes. In a non-TTY, pass `--budget-u`, `--expiry-days`, optional `--no-register`, and `--yes`. Stdout from a successful grant is only the session public key.
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
The grant persists the owner-only session, provisions a Commerce allowance no higher than its U-token cap, and approves the quote checker. Every relay-backed management write must return `CONFIRMED`; `PENDING` fails closed and preserves the session file. If either setup step fails after the paid grant, repair both with:
|
|
38
39
|
|
|
39
40
|
```bash
|
|
40
41
|
bag wallet session grant --approve-only
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
Replace with `grant --force`; the old
|
|
44
|
+
`--approve-only`, runtime loading, and deploy readiness reject pre-0.5.4 target-wide sessions. Replace one with `grant --force`; Studio zeros the old Commerce allowance before revoking, and both operations must be confirmed before the new grant begins. Revoke with `bag wallet session revoke --yes`; the file is deleted only after both confirmations.
|
|
44
45
|
|
|
45
46
|
x402 buying remains separate and exact-bounded:
|
|
46
47
|
|