@bnbagent/studio-cli 0.0.6-alpha.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/DISCLAIMER.md +48 -0
- package/LICENSE +201 -0
- package/dist/_agentcoreName-DZDWEYD3.js +7 -0
- package/dist/_twak-5XQMOFUC.js +25 -0
- package/dist/bag.js +19358 -0
- package/dist/chunk-7RAKL4AS.js +172 -0
- package/dist/chunk-M3ODFCA7.js +1053 -0
- package/dist/chunk-U7IDQ3K5.js +14 -0
- package/dist/deployCli-N6TPN6XA.js +40 -0
- package/package.json +64 -0
- package/recipes/agent/code/{{PKG}}/signing.ts.tmpl +287 -0
- package/recipes/agent/recipe.toml +35 -0
- package/recipes/providers/pieverse-llm/recipe.toml +16 -0
- package/recipes/providers/pieverse-llm/skills/funding-pieverse-llm.md +203 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/.dockerignore.tmpl +8 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/Dockerfile.tmpl +50 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/agentCard.ts.tmpl +135 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/dualMain.ts.tmpl +402 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/executor.ts.tmpl +147 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/main.ts.tmpl +344 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/mcpMain.ts.tmpl +677 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/model.ts.tmpl +117 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/sellerCore.ts.tmpl +503 -0
- package/recipes/runtimes/agentcore/code/{{PKG}}/tools.ts.tmpl +157 -0
- package/recipes/runtimes/agentcore/recipe.toml +97 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/.dockerignore.tmpl +8 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/Dockerfile.tmpl +47 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/agentCard.ts.tmpl +131 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/executor.ts.tmpl +504 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/foundryMain.ts.tmpl +300 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/main.ts.tmpl +196 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/mcpMain.ts.tmpl +562 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/model.ts.tmpl +117 -0
- package/recipes/runtimes/azure-foundry/code/{{PKG}}/tools.ts.tmpl +157 -0
- package/recipes/runtimes/azure-foundry/recipe.toml +88 -0
- package/recipes/tools-chain/code/{{PKG}}/chainTools.ts.tmpl +166 -0
- package/recipes/tools-chain/recipe.toml +11 -0
- package/recipes/wallet/recipe.toml +20 -0
- package/recipes/x402-buyer/code/{{PKG}}/x402Buyer.ts.tmpl +175 -0
- package/recipes/x402-buyer/recipe.toml +15 -0
- package/skills/bnbagent-studio.md +107 -0
- package/skills/references/bnbagent-studio-adding-to-project.md +241 -0
- package/skills/references/bnbagent-studio-buying-from-bazaar.md +169 -0
- package/skills/references/bnbagent-studio-buying-via-8183.md +222 -0
- package/skills/references/bnbagent-studio-extending-signing.md +227 -0
- package/skills/references/bnbagent-studio-operating.md +211 -0
- package/skills/references/bnbagent-studio-scaffolding-agent.md +536 -0
- package/skills/references/bnbagent-studio-selling-via-8183.md +271 -0
- package/skills/references/bnbagent-studio-selling-via-b402.md +194 -0
- package/skills/references/bnbagent-studio-use-aws-agentcore.md +208 -0
- package/skills/references/bnbagent-studio-use-azure-foundry.md +164 -0
- package/skills/references/bnbagent-studio-use-bnb-trial.md +92 -0
- package/skills/references/bnbagent-studio-using-altana-wallet.md +68 -0
- package/skills/references/bnbagent-studio-using-twak-wallet.md +260 -0
- package/skills/references/bnbagent-studio-wiring-llm-tools.md +338 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model factory — emitted user code shared by every runtime entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* This file is **your project's code**, scaffolded by `bag` and emitted at
|
|
5
|
+
* `{{PKG}}/model.ts` for every project, whatever `[llm].provider` you chose.
|
|
6
|
+
* It is yours to edit, fork, or replace — studio will not silently rewrite
|
|
7
|
+
* it.
|
|
8
|
+
*
|
|
9
|
+
* What it does:
|
|
10
|
+
*
|
|
11
|
+
* - Exposes {@link buildModel}, the factory called by the sibling entrypoint
|
|
12
|
+
* to construct the right AI SDK `LanguageModel` for the project's `[llm]`
|
|
13
|
+
* config. For every provider it resolves a plain model via
|
|
14
|
+
* `@bnbagent/studio-runtime/llm` `resolveModel`; for `pieverse-llm` (with
|
|
15
|
+
* auto-renew on) it additionally wraps it with a credit-ensure middleware.
|
|
16
|
+
* - The middleware awaits a Pieverse credit-ensure hook before every
|
|
17
|
+
* generate/stream call — inert unless the provider is `pieverse-llm`.
|
|
18
|
+
*
|
|
19
|
+
* The credit-refresh / auto-allocate / auto-topup logic itself lives in the
|
|
20
|
+
* library at `@bnbagent/studio-runtime/pieverse` `PieverseCreditEnsurer` —
|
|
21
|
+
* this shell just wires it into the AI SDK's generate-call path (via
|
|
22
|
+
* `wrapLanguageModel`, the AI SDK's sanctioned middleware seam). That keeps
|
|
23
|
+
* the adapter tiny: you can fork *this file* (e.g. to swap the AI SDK for
|
|
24
|
+
* another stack) without forking studio itself.
|
|
25
|
+
*
|
|
26
|
+
* That top-up is the ONLY automatic signing path outside signing.ts — it is
|
|
27
|
+
* budget-gated and is NOT an LLM tool. It rides transparently into the
|
|
28
|
+
* `notify_funded` work step.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { loadStudioToml, type TomlTable } from "@bnbagent/studio-runtime/config";
|
|
32
|
+
import { resolveModel } from "@bnbagent/studio-runtime/llm";
|
|
33
|
+
import {
|
|
34
|
+
BudgetPolicy,
|
|
35
|
+
PieverseCreditEnsurer,
|
|
36
|
+
PieversePolicy,
|
|
37
|
+
} from "@bnbagent/studio-runtime/pieverse";
|
|
38
|
+
import { getWallet } from "@bnbagent/studio-runtime/wallet";
|
|
39
|
+
import {
|
|
40
|
+
type LanguageModel,
|
|
41
|
+
type LanguageModelMiddleware,
|
|
42
|
+
wrapLanguageModel,
|
|
43
|
+
} from "ai";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the AI SDK model object for this project's `[llm]` config.
|
|
47
|
+
*
|
|
48
|
+
* Called by the sibling entrypoint. Reads `studio.toml` via
|
|
49
|
+
* `loadStudioToml`, resolves the provider via
|
|
50
|
+
* `@bnbagent/studio-runtime/llm` `resolveModel`, and (when the provider is
|
|
51
|
+
* `pieverse-llm` and auto-renew is enabled) wraps it with the credit-ensure
|
|
52
|
+
* middleware.
|
|
53
|
+
*
|
|
54
|
+
* For non-Pieverse providers — or when `[llm.auto_renew].enabled = false` —
|
|
55
|
+
* returns the raw inner model unwrapped.
|
|
56
|
+
*/
|
|
57
|
+
export function buildModel(): LanguageModel {
|
|
58
|
+
const cfg = loadStudioToml();
|
|
59
|
+
const llmCfg = (cfg.llm ?? {}) as TomlTable;
|
|
60
|
+
const inner = resolveModel(llmCfg);
|
|
61
|
+
|
|
62
|
+
if (String(llmCfg.provider ?? "openrouter") !== "pieverse-llm") {
|
|
63
|
+
return inner;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Pieverse path — wrap with the credit-ensure middleware unless auto-renew
|
|
67
|
+
// opted out.
|
|
68
|
+
const autoRenewCfg = (llmCfg.auto_renew ?? {}) as TomlTable;
|
|
69
|
+
const pieverseCfg = (llmCfg.pieverse ?? {}) as TomlTable;
|
|
70
|
+
const budgetCfg = (cfg.budget ?? {}) as TomlTable;
|
|
71
|
+
|
|
72
|
+
const policy = PieversePolicy.fromToml(autoRenewCfg);
|
|
73
|
+
if (!policy.enabled) {
|
|
74
|
+
return inner;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const keyHash = pieverseCfg.key_hash;
|
|
78
|
+
if (!keyHash) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"[llm.pieverse].key_hash is missing in studio.toml. " +
|
|
81
|
+
"Run `bag llm activate` to create a Pieverse key first. " +
|
|
82
|
+
"(After activate, you may need to restart the agent process " +
|
|
83
|
+
"for changes to take effect.)",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const networkName = String(pieverseCfg.network ?? "bsc-mainnet");
|
|
87
|
+
const budgetPolicy = BudgetPolicy.fromToml(budgetCfg);
|
|
88
|
+
|
|
89
|
+
const ensurer = new PieverseCreditEnsurer({
|
|
90
|
+
modelId: String(llmCfg.model ?? ""),
|
|
91
|
+
wallet: getWallet(),
|
|
92
|
+
keyHash: String(keyHash),
|
|
93
|
+
networkName,
|
|
94
|
+
policy,
|
|
95
|
+
budgetPolicy,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The AI SDK middleware seam: ensure credits BEFORE each generate/stream
|
|
99
|
+
// call, then delegate untouched through the AI SDK middleware seam.
|
|
100
|
+
const creditEnsure: LanguageModelMiddleware = {
|
|
101
|
+
wrapGenerate: async ({ doGenerate }) => {
|
|
102
|
+
await ensurer.ensureCredits();
|
|
103
|
+
return doGenerate();
|
|
104
|
+
},
|
|
105
|
+
wrapStream: async ({ doStream }) => {
|
|
106
|
+
await ensurer.ensureCredits();
|
|
107
|
+
return doStream();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// resolveModel always returns a provider model object (never a bare model
|
|
112
|
+
// id string), so it satisfies wrapLanguageModel's model parameter.
|
|
113
|
+
return wrapLanguageModel({
|
|
114
|
+
model: inner as Parameters<typeof wrapLanguageModel>[0]["model"],
|
|
115
|
+
middleware: creditEnsure,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seller core — the a2a-free seller logic + background delivery machinery.
|
|
3
|
+
*
|
|
4
|
+
* This is the protocol-neutral heart of the ERC-8183 seller: the two fixed-code
|
|
5
|
+
* operations (`negotiate` → signed quote; `notifyFunded` → verify → ACK →
|
|
6
|
+
* deliver in the background) plus the background-delivery bookkeeping
|
|
7
|
+
* (`isBusy`, the spawn/run/sweep helpers). It imports NOTHING from
|
|
8
|
+
* `@a2a-js/sdk` so it can back any transport — the A2A executor
|
|
9
|
+
* (`executor.ts`) inherits it and wraps it with the a2a wire, and a non-A2A
|
|
10
|
+
* HTTP entrypoint can call it directly without dragging in the a2a sdk.
|
|
11
|
+
*
|
|
12
|
+
* negotiate → `signing.signQuote` (rule-based price clamp + EIP-191 sign)
|
|
13
|
+
* notifyFunded → `signing.verifySignedJob` (fast on-chain gate) → ACK at
|
|
14
|
+
* once, then in the BACKGROUND: LLM work → `signing.submitResult`
|
|
15
|
+
*
|
|
16
|
+
* `notifyFunded` is the buyer's "I funded job X — please deliver" notification.
|
|
17
|
+
* Because the work takes time, it does NOT block the caller: it verifies the
|
|
18
|
+
* funded job synchronously (a couple of eth_calls) to ACK accepted/rejected,
|
|
19
|
+
* then runs the slow LLM work + on-chain `submit` in a background task and
|
|
20
|
+
* returns immediately. The buyer reads the deliverable back from the CHAIN
|
|
21
|
+
* (SUBMITTED / `getDeliverableUrl`) — the chain is the source of truth. While
|
|
22
|
+
* any background delivery is in flight {@link SellerCore.isBusy} reports busy,
|
|
23
|
+
* which the transport feeds to AgentCore's `/ping` as `HEALTHY_BUSY` so the
|
|
24
|
+
* scale-to-zero runtime stays warm until the work lands (within the session
|
|
25
|
+
* max-lifetime).
|
|
26
|
+
*
|
|
27
|
+
* ALL signing is FIXED code in `signing.ts` — NEVER an LLM-callable tool
|
|
28
|
+
* (money is never in the LLM; the LLM only produces the work text, via the
|
|
29
|
+
* `runWork` hook). On each notification the core also opportunistically sweeps
|
|
30
|
+
* OTHER funded jobs assigned to this provider — the buyer-push fallback for
|
|
31
|
+
* jobs whose buyer funded on-chain but never sent `notify_funded` (deduped
|
|
32
|
+
* against in-flight jobs). Negotiate stays sweep-free so quotes are fast. A
|
|
33
|
+
* periodic Lambda poller — which also covers the scale-to-zero cold window
|
|
34
|
+
* when no one is invoking — is the v2 robust path.
|
|
35
|
+
*
|
|
36
|
+
* You own this file — specialise the work hook / dispatch, but keep signing
|
|
37
|
+
* OUT of the LLM tool list.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { ERC8183JobOps } from "@bnbagent/sdk/erc8183";
|
|
41
|
+
import { SubmitPermanentlyUnsupportedError } from "@bnbagent/studio-runtime/erc8183";
|
|
42
|
+
import { getWallet } from "@bnbagent/studio-runtime/wallet";
|
|
43
|
+
import * as defaultSigning from "./signing.js";
|
|
44
|
+
|
|
45
|
+
const log = {
|
|
46
|
+
info: (msg: string) => console.log(`[seller-agent.core] ${msg}`),
|
|
47
|
+
warn: (msg: string) => console.warn(`[seller-agent.core] WARNING ${msg}`),
|
|
48
|
+
error: (msg: string, e?: unknown) =>
|
|
49
|
+
console.error(`[seller-agent.core] ERROR ${msg}`, e ?? ""),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Read a positive timeout (seconds) from the env, falling back to `dflt`. */
|
|
53
|
+
function envSeconds(name: string, dflt: number): number {
|
|
54
|
+
const v = Number(process.env[name] || dflt);
|
|
55
|
+
return Number.isFinite(v) && v > 0 ? v : dflt;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Background-task ceilings. notifyFunded ACKs immediately and delivers in a
|
|
59
|
+
// BACKGROUND task; AgentCore keeps the scale-to-zero microVM warm
|
|
60
|
+
// (HEALTHY_BUSY) while isBusy() is true. A delivery (LLM text + on-chain
|
|
61
|
+
// submit + IPFS pin) normally finishes in ~1-2 min, so these caps sit far
|
|
62
|
+
// above real work and only fire on a HANG (e.g. an unresponsive RPC) —
|
|
63
|
+
// without them a hung task keeps the VM pinned to its 8h max-lifetime,
|
|
64
|
+
// billing memory the whole time. A timed-out job is treated as TRANSIENT
|
|
65
|
+
// (not dropped): the funded job stays on-chain and a later sweep re-delivers
|
|
66
|
+
// it idempotently. (Read lazily so tests can tune them via the env.)
|
|
67
|
+
const jobDeliveryTimeoutSeconds = () =>
|
|
68
|
+
envSeconds("NOTIFY_DELIVERY_TIMEOUT_SECONDS", 600);
|
|
69
|
+
const sweepTimeoutSeconds = () => envSeconds("NOTIFY_SWEEP_TIMEOUT_SECONDS", 60);
|
|
70
|
+
const preverifyTimeoutSeconds = () =>
|
|
71
|
+
envSeconds("NOTIFY_PREVERIFY_TIMEOUT_SECONDS", 30);
|
|
72
|
+
|
|
73
|
+
/** Rejection raised by {@link withTimeout} when the deadline fires. */
|
|
74
|
+
export class DeliveryTimeoutError extends Error {}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Race `work` against a deadline, aborting `controller` when it fires.
|
|
78
|
+
*
|
|
79
|
+
* JS cannot hard-cancel an arbitrary promise the way asyncio.wait_for
|
|
80
|
+
* cancels a coroutine: the abort signal stops the LLM call (the AI SDK
|
|
81
|
+
* honours it), and the on-chain layers are idempotent — `verifySignedJob`
|
|
82
|
+
* returns non-OK for an already-SUBMITTED job and `submitResult` re-verifies
|
|
83
|
+
* FUNDED — so an orphaned straggler can never double-deliver.
|
|
84
|
+
*/
|
|
85
|
+
async function withTimeout<T>(
|
|
86
|
+
work: Promise<T>,
|
|
87
|
+
seconds: number,
|
|
88
|
+
controller?: AbortController,
|
|
89
|
+
): Promise<T> {
|
|
90
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
91
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
92
|
+
timer = setTimeout(() => {
|
|
93
|
+
controller?.abort();
|
|
94
|
+
reject(new DeliveryTimeoutError(`timed out after ${seconds}s`));
|
|
95
|
+
}, seconds * 1000);
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
return await Promise.race([work, deadline]);
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The LLM work hook: produce the deliverable text for a prompt.
|
|
106
|
+
*
|
|
107
|
+
* Built in `main.ts` from the AI SDK (`generateText` + the read-only chain
|
|
108
|
+
* tools); called ONLY inside the background delivery. `abortSignal` is wired
|
|
109
|
+
* to the delivery timeout so a hung LLM call is actually cancelled.
|
|
110
|
+
*/
|
|
111
|
+
export type RunWork = (
|
|
112
|
+
prompt: string,
|
|
113
|
+
opts: { sessionId: string; abortSignal?: AbortSignal },
|
|
114
|
+
) => Promise<string>;
|
|
115
|
+
|
|
116
|
+
/** The `signing.ts` surface the core drives (injectable for tests). */
|
|
117
|
+
export interface SigningApi {
|
|
118
|
+
listPrice(): bigint;
|
|
119
|
+
clampPrice(proposedWei: bigint): bigint;
|
|
120
|
+
signQuote(
|
|
121
|
+
request: Record<string, unknown>,
|
|
122
|
+
clampedPriceWei: bigint,
|
|
123
|
+
): Promise<Record<string, unknown>>;
|
|
124
|
+
verifySignedJob(
|
|
125
|
+
jobId: number,
|
|
126
|
+
): Promise<{ ok: boolean; reason: string; permanent: boolean }>;
|
|
127
|
+
jobSpec(
|
|
128
|
+
jobId: number,
|
|
129
|
+
): Promise<{ task: string; terms: Record<string, unknown> } | null>;
|
|
130
|
+
submitResult(
|
|
131
|
+
jobId: number,
|
|
132
|
+
responseContent: string,
|
|
133
|
+
metadata?: Record<string, unknown> | null,
|
|
134
|
+
): Promise<{ submitTx: string; deliverableUrl: string | null }>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Pending-job scanner used by the sweep (injectable for tests). */
|
|
138
|
+
export type PendingJobsFetcher = (
|
|
139
|
+
network: string,
|
|
140
|
+
) => Promise<Record<string, unknown>>;
|
|
141
|
+
|
|
142
|
+
const defaultPendingJobs: PendingJobsFetcher = async (network) => {
|
|
143
|
+
const ops = await ERC8183JobOps.create({
|
|
144
|
+
walletProvider: getWallet(),
|
|
145
|
+
network,
|
|
146
|
+
});
|
|
147
|
+
return (await ops.getPendingJobs()) as Record<string, unknown>;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export interface SellerCoreOpts {
|
|
151
|
+
runWork: RunWork;
|
|
152
|
+
generator: string;
|
|
153
|
+
network?: string | null;
|
|
154
|
+
/** Whether the project configured the ERC-8183 commerce rail. */
|
|
155
|
+
commerceSkills?: boolean;
|
|
156
|
+
/** Test seam: replace the signing module (default: `./signing.js`). */
|
|
157
|
+
signing?: SigningApi;
|
|
158
|
+
/** Test seam: replace the sweep's pending-job scan. */
|
|
159
|
+
pendingJobs?: PendingJobsFetcher;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* ERC-8183 seller core: negotiate + notifyFunded, backed by signing.ts.
|
|
164
|
+
*
|
|
165
|
+
* `runWork(prompt, { sessionId })` is the LLM work hook (built in `main.ts`
|
|
166
|
+
* from the AI SDK); it is called inside the background delivery
|
|
167
|
+
* (`notifyFunded` → `doWorkAndSubmit`) to produce the deliverable text.
|
|
168
|
+
*
|
|
169
|
+
* The core exposes ONLY the two paid, structured operations — there is no
|
|
170
|
+
* free-form chat operation. The transport is responsible for routing a
|
|
171
|
+
* request to {@link negotiate} / {@link notifyFunded}; a request that names
|
|
172
|
+
* no structured operation must never trigger an LLM call or a paid action.
|
|
173
|
+
*/
|
|
174
|
+
export class SellerCore {
|
|
175
|
+
protected readonly runWork: RunWork;
|
|
176
|
+
protected readonly generator: string;
|
|
177
|
+
protected readonly network: string;
|
|
178
|
+
protected readonly signing: SigningApi;
|
|
179
|
+
private readonly commerceSkills: boolean;
|
|
180
|
+
private readonly pendingJobs: PendingJobsFetcher;
|
|
181
|
+
// Background delivery bookkeeping (see notifyFunded / isBusy):
|
|
182
|
+
// tasks — live background promises (busy-status source).
|
|
183
|
+
// inflight — job ids in flight OR already terminally handled this
|
|
184
|
+
// process (notify/sweep dedup; retained on success so a
|
|
185
|
+
// slower sweep never re-delivers a just-submitted job).
|
|
186
|
+
private readonly tasks = new Set<Promise<void>>();
|
|
187
|
+
private readonly inflight = new Set<number>();
|
|
188
|
+
|
|
189
|
+
constructor(opts: SellerCoreOpts) {
|
|
190
|
+
this.runWork = opts.runWork;
|
|
191
|
+
this.generator = opts.generator;
|
|
192
|
+
this.network = opts.network ?? "bsc-testnet";
|
|
193
|
+
this.signing = opts.signing ?? defaultSigning;
|
|
194
|
+
this.commerceSkills = opts.commerceSkills ?? true;
|
|
195
|
+
this.pendingJobs = opts.pendingJobs ?? defaultPendingJobs;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True while any background delivery is in flight.
|
|
200
|
+
*
|
|
201
|
+
* The transport feeds this to AgentCore's `/ping` (`HEALTHY_BUSY` when
|
|
202
|
+
* busy) so the scale-to-zero runtime is not reaped on idle while work runs.
|
|
203
|
+
*/
|
|
204
|
+
isBusy(): boolean {
|
|
205
|
+
return this.tasks.size > 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Await every in-flight background task (test helper — not on the wire). */
|
|
209
|
+
async drain(): Promise<void> {
|
|
210
|
+
while (this.tasks.size > 0) {
|
|
211
|
+
await Promise.allSettled([...this.tasks]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── skills ──────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Rule-based quote → SDK `NegotiationResult` envelope (no LLM).
|
|
219
|
+
*
|
|
220
|
+
* The price is the FIXED list price from studio.toml, clamped to
|
|
221
|
+
* `[min,max]` BEFORE signing — a misconfigured or hostile request can
|
|
222
|
+
* never sign out of bounds. The buyer parses this envelope verbatim and
|
|
223
|
+
* anchors it on-chain via `createJob` + `fund`.
|
|
224
|
+
*/
|
|
225
|
+
async negotiate(
|
|
226
|
+
data: Record<string, unknown>,
|
|
227
|
+
): Promise<Record<string, unknown>> {
|
|
228
|
+
this.requireCommerceRail();
|
|
229
|
+
let request = data.request;
|
|
230
|
+
if (request === null || typeof request !== "object" || Array.isArray(request)) {
|
|
231
|
+
const picked: Record<string, unknown> = {};
|
|
232
|
+
for (const k of ["task_description", "terms"]) {
|
|
233
|
+
if (k in data) picked[k] = data[k];
|
|
234
|
+
}
|
|
235
|
+
request = picked;
|
|
236
|
+
}
|
|
237
|
+
const clamped = this.signing.clampPrice(this.signing.listPrice());
|
|
238
|
+
return this.signing.signQuote(request as Record<string, unknown>, clamped);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The seller's two advertised skills. */
|
|
242
|
+
skills(): string[] {
|
|
243
|
+
return this.commerceSkills ? ["negotiate", "notify_funded"] : [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Buyer notification: "I funded job X — please deliver."
|
|
248
|
+
*
|
|
249
|
+
* Verify the funded job synchronously (a couple of eth_calls) to ACK
|
|
250
|
+
* accepted/rejected at once, then run the slow LLM work + on-chain
|
|
251
|
+
* `submit` in a BACKGROUND task and return IMMEDIATELY. The buyer reads
|
|
252
|
+
* the deliverable back from the CHAIN (SUBMITTED / `getDeliverableUrl`) —
|
|
253
|
+
* the chain is the source of truth (see erc8183-buyer-push.md).
|
|
254
|
+
*
|
|
255
|
+
* An accepted notification also kicks a background sweep (deduped against
|
|
256
|
+
* in-flight jobs), so a buyer that funded but forgot to notify is still
|
|
257
|
+
* served while we're warm. A rejected / malformed notification spawns
|
|
258
|
+
* nothing.
|
|
259
|
+
*/
|
|
260
|
+
async notifyFunded(
|
|
261
|
+
data: Record<string, unknown>,
|
|
262
|
+
): Promise<Record<string, unknown>> {
|
|
263
|
+
this.requireCommerceRail();
|
|
264
|
+
const raw = data.job_id;
|
|
265
|
+
if (raw === undefined || raw === null || String(raw) === "") {
|
|
266
|
+
this.spawn(() => this.sweep()); // bare notify → just scan stragglers
|
|
267
|
+
return {
|
|
268
|
+
status: "accepted",
|
|
269
|
+
note: "no job_id — scanning funded jobs in the background; poll the chain for results",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
let jobId: number;
|
|
273
|
+
try {
|
|
274
|
+
jobId = parseJobId(raw);
|
|
275
|
+
} catch {
|
|
276
|
+
return { status: "rejected", error: `invalid job_id: ${JSON.stringify(raw)}` };
|
|
277
|
+
}
|
|
278
|
+
let verified = false;
|
|
279
|
+
try {
|
|
280
|
+
// Time-bounded: a hung RPC must not stall the ack path. On timeout we
|
|
281
|
+
// fall through to accept-and-re-verify below.
|
|
282
|
+
const v = await withTimeout(
|
|
283
|
+
this.signing.verifySignedJob(jobId),
|
|
284
|
+
preverifyTimeoutSeconds(),
|
|
285
|
+
);
|
|
286
|
+
if (!v.ok && v.permanent) {
|
|
287
|
+
return { status: "rejected", job_id: jobId, reason: v.reason };
|
|
288
|
+
}
|
|
289
|
+
verified = v.ok;
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// pre-verify is best-effort; the background delivery re-verifies
|
|
292
|
+
log.warn(
|
|
293
|
+
`pre-verify of job ${jobId} failed (${e instanceof Error ? e.message : e}); accepting, will re-verify in background`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
this.spawnJob(jobId, { verified });
|
|
297
|
+
this.spawn(() => this.sweep()); // straggler fallback alongside the named job
|
|
298
|
+
return {
|
|
299
|
+
status: "accepted",
|
|
300
|
+
job_id: jobId,
|
|
301
|
+
note: "delivery started; poll the chain (SUBMITTED / get_deliverable_url) for the result",
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── background delivery ──────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/** Run `work` as a tracked background task (keeps {@link isBusy} true). */
|
|
308
|
+
protected spawn(work: () => Promise<void>): void {
|
|
309
|
+
const task = work().catch((e) => {
|
|
310
|
+
// a background task must never crash the process
|
|
311
|
+
log.error("background task failed", e);
|
|
312
|
+
});
|
|
313
|
+
this.tasks.add(task);
|
|
314
|
+
task.finally(() => this.tasks.delete(task));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Background-deliver `jobId` once, deduped against in-flight jobs.
|
|
319
|
+
*
|
|
320
|
+
* `inflight` is updated SYNCHRONOUSLY here (before scheduling) so a
|
|
321
|
+
* concurrent notify + sweep can never double-deliver the same job.
|
|
322
|
+
*/
|
|
323
|
+
private spawnJob(jobId: number, opts: { verified: boolean }): void {
|
|
324
|
+
if (this.inflight.has(jobId)) return;
|
|
325
|
+
this.inflight.add(jobId);
|
|
326
|
+
this.spawn(() => this.runJob(jobId, opts));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Background runner: deliver one job, log the outcome, free the slot.
|
|
331
|
+
*
|
|
332
|
+
* `verified` jobs (pre-verified in `notifyFunded`) skip straight to the
|
|
333
|
+
* work; unverified ones (the sweep) run the full verify gate first.
|
|
334
|
+
*/
|
|
335
|
+
private async runJob(
|
|
336
|
+
jobId: number,
|
|
337
|
+
{ verified }: { verified: boolean },
|
|
338
|
+
): Promise<void> {
|
|
339
|
+
let terminal = false;
|
|
340
|
+
const controller = new AbortController();
|
|
341
|
+
try {
|
|
342
|
+
// Hard ceiling so a hung delivery (e.g. unresponsive RPC) cannot keep
|
|
343
|
+
// isBusy() true — which would pin the microVM to its 8h max-lifetime.
|
|
344
|
+
// A timeout is TRANSIENT: terminal stays false, the slot is freed, and
|
|
345
|
+
// the funded job is re-delivered idempotently by a later sweep.
|
|
346
|
+
const result = await withTimeout(
|
|
347
|
+
verified
|
|
348
|
+
? this.doWorkAndSubmit(jobId, controller.signal)
|
|
349
|
+
: this.fulfillJob(jobId, controller.signal),
|
|
350
|
+
jobDeliveryTimeoutSeconds(),
|
|
351
|
+
controller,
|
|
352
|
+
);
|
|
353
|
+
log.info(`notify_funded job ${jobId} → ${JSON.stringify(result)}`);
|
|
354
|
+
// A terminal outcome (delivered, or a permanent skip) must STAY in
|
|
355
|
+
// `inflight`: keeping it lets the dedup gate in spawnJob reject a
|
|
356
|
+
// slower concurrent sweep that still sees this job as FUNDED, so the
|
|
357
|
+
// just-submitted job is never re-delivered. Clearing on success
|
|
358
|
+
// reopened that race — the sweep re-ran the work and then failed the
|
|
359
|
+
// on-chain FUNDED gate (Job status is SUBMITTED). Only transient
|
|
360
|
+
// failures fall through to delete so a later sweep can retry them.
|
|
361
|
+
terminal = Boolean(result.ok || result.skip);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
if (e instanceof DeliveryTimeoutError) {
|
|
364
|
+
// Transient by design — leave terminal false so a later sweep retries.
|
|
365
|
+
log.warn(
|
|
366
|
+
`background delivery of job ${jobId} timed out after ${jobDeliveryTimeoutSeconds()}s; will retry`,
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
log.error(`background delivery of job ${jobId} failed`, e);
|
|
370
|
+
}
|
|
371
|
+
} finally {
|
|
372
|
+
if (!terminal) {
|
|
373
|
+
this.inflight.delete(jobId);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private requireCommerceRail(): void {
|
|
379
|
+
if (!this.commerceSkills) {
|
|
380
|
+
throw new Error("8183 rail disabled");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Verify the signed deal on-chain, then deliver (the sweep's per-job worker).
|
|
388
|
+
*
|
|
389
|
+
* VERIFY before working: confirm the funded job carries the exact quote
|
|
390
|
+
* THIS agent signed (ecrecover + budget ≥ price). A permanent failure
|
|
391
|
+
* (not our signature, tampered terms, underfunded, expired) returns
|
|
392
|
+
* `skip: true`; a transient one returns `ok: false` to retry.
|
|
393
|
+
*/
|
|
394
|
+
private async fulfillJob(
|
|
395
|
+
jobId: number,
|
|
396
|
+
abortSignal: AbortSignal,
|
|
397
|
+
): Promise<Record<string, unknown>> {
|
|
398
|
+
const v = await this.signing.verifySignedJob(jobId);
|
|
399
|
+
if (!v.ok) {
|
|
400
|
+
return { ok: false, job_id: jobId, skip: v.permanent, reason: v.reason };
|
|
401
|
+
}
|
|
402
|
+
return this.doWorkAndSubmit(jobId, abortSignal);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* LLM work → sign + submit. Assumes `jobId` is already verified.
|
|
407
|
+
*
|
|
408
|
+
* DEVELOPER HOOK: the LLM block produces the deliverable text — specialise
|
|
409
|
+
* it for your seller. `signing.submitResult` re-runs the SDK `verifyJob`
|
|
410
|
+
* (defense in depth) and THROWS on a failed submit, so an `ok: true`
|
|
411
|
+
* result always carries a landed tx hash.
|
|
412
|
+
*/
|
|
413
|
+
protected async doWorkAndSubmit(
|
|
414
|
+
jobId: number,
|
|
415
|
+
abortSignal?: AbortSignal,
|
|
416
|
+
): Promise<Record<string, unknown>> {
|
|
417
|
+
const spec = await this.signing.jobSpec(jobId);
|
|
418
|
+
const task =
|
|
419
|
+
spec !== null
|
|
420
|
+
? JSON.stringify({ task: spec.task, terms: spec.terms })
|
|
421
|
+
: `job ${jobId}`;
|
|
422
|
+
const prompt =
|
|
423
|
+
"You accepted and were paid for the following job. Produce the " +
|
|
424
|
+
"deliverable now. Be complete and self-contained.\n\n" +
|
|
425
|
+
`JOB CONTEXT:\n${task}`;
|
|
426
|
+
const work = await this.runWork(prompt, {
|
|
427
|
+
sessionId: String(jobId),
|
|
428
|
+
abortSignal,
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
let res: { submitTx: string; deliverableUrl: string | null };
|
|
432
|
+
try {
|
|
433
|
+
res = await this.signing.submitResult(jobId, work, {
|
|
434
|
+
job_id: jobId,
|
|
435
|
+
generator: this.generator,
|
|
436
|
+
built_with: "https://github.com/bnb-chain/bnbagent-studio",
|
|
437
|
+
});
|
|
438
|
+
} catch (e) {
|
|
439
|
+
if (
|
|
440
|
+
e instanceof SubmitPermanentlyUnsupportedError ||
|
|
441
|
+
(e instanceof Error && e.name === "SubmitPermanentlyUnsupportedError")
|
|
442
|
+
) {
|
|
443
|
+
// Deterministic for this wallet kind: submit can NEVER succeed →
|
|
444
|
+
// permanent skip (a transient error would burn one LLM call / retry).
|
|
445
|
+
return { ok: false, job_id: jobId, skip: true, reason: e.message };
|
|
446
|
+
}
|
|
447
|
+
throw e;
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
ok: true,
|
|
451
|
+
job_id: jobId,
|
|
452
|
+
tx_hash: res.submitTx,
|
|
453
|
+
deliverable_url: res.deliverableUrl,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Best-effort background fallback: deliver any FUNDED jobs for this
|
|
459
|
+
* provider.
|
|
460
|
+
*
|
|
461
|
+
* Catches jobs whose buyer funded on-chain but never sent `notify_funded`.
|
|
462
|
+
* Each job is handed to `spawnJob` (deduped against in-flight jobs, so a
|
|
463
|
+
* concurrent notify never double-delivers); `verifySignedJob` returns
|
|
464
|
+
* non-OK for an already-SUBMITTED job (idempotent, no state file). Errors
|
|
465
|
+
* here are logged and never surface to the caller.
|
|
466
|
+
*/
|
|
467
|
+
private async sweep(): Promise<void> {
|
|
468
|
+
let pending: Record<string, unknown>;
|
|
469
|
+
try {
|
|
470
|
+
// Time-bounded: a hung scan would otherwise keep isBusy() true (it
|
|
471
|
+
// runs on every notify) and pin the microVM to its 8h max-lifetime.
|
|
472
|
+
pending = await withTimeout(
|
|
473
|
+
this.pendingJobs(this.network),
|
|
474
|
+
sweepTimeoutSeconds(),
|
|
475
|
+
);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
// the sweep is best-effort (incl. timeouts)
|
|
478
|
+
log.warn(`funded-job sweep failed: ${e instanceof Error ? e.message : e}`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const jobs = Array.isArray(pending?.jobs) ? pending.jobs : [];
|
|
482
|
+
for (const job of jobs) {
|
|
483
|
+
const jid =
|
|
484
|
+
job !== null && typeof job === "object" && !Array.isArray(job)
|
|
485
|
+
? (job as Record<string, unknown>).jobId
|
|
486
|
+
: undefined;
|
|
487
|
+
if (jid === undefined || jid === null) continue;
|
|
488
|
+
try {
|
|
489
|
+
this.spawnJob(parseJobId(jid), { verified: false });
|
|
490
|
+
} catch {
|
|
491
|
+
// unparseable id — skip
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Normalise an envelope `job_id` (`0x..` / decimal string / number) to int. */
|
|
498
|
+
export function parseJobId(raw: unknown): number {
|
|
499
|
+
if (typeof raw === "number" && Number.isInteger(raw)) return raw;
|
|
500
|
+
if (typeof raw === "bigint") return Number(raw);
|
|
501
|
+
// BigInt() parses both `0x..` hex and decimal strings, and throws on junk.
|
|
502
|
+
return Number(BigInt(String(raw).trim()));
|
|
503
|
+
}
|