@bondedhq/runner 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +274 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +120 -0
- package/dist/code-hash.d.ts +54 -0
- package/dist/code-hash.js +110 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/init/apply.d.ts +22 -0
- package/dist/init/apply.js +73 -0
- package/dist/init/check.d.ts +79 -0
- package/dist/init/check.js +158 -0
- package/dist/init/command.d.ts +34 -0
- package/dist/init/command.js +314 -0
- package/dist/init/detect.d.ts +34 -0
- package/dist/init/detect.js +184 -0
- package/dist/init/diff.d.ts +6 -0
- package/dist/init/diff.js +98 -0
- package/dist/init/plan.d.ts +44 -0
- package/dist/init/plan.js +652 -0
- package/dist/runner.d.ts +78 -0
- package/dist/runner.js +319 -0
- package/dist/service.d.ts +24 -0
- package/dist/service.js +169 -0
- package/dist/tail.d.ts +21 -0
- package/dist/tail.js +50 -0
- package/examples/agent-url-agent.mjs +140 -0
- package/examples/mcp-agent.mjs +60 -0
- package/package.json +60 -3
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, posix } from "node:path";
|
|
3
|
+
import { FRAMEWORK_NAMES } from "./detect.js";
|
|
4
|
+
/**
|
|
5
|
+
* The first line of every file `init` writes, so a second run knows the file is init's. It is
|
|
6
|
+
* rewritten only while it still holds exactly what init would write (or with --overwrite-generated).
|
|
7
|
+
*/
|
|
8
|
+
export const MARKER = "// Added by `bonded-runner init`";
|
|
9
|
+
const GUIDE = "https://github.com/ddboy19912/bonded/blob/main/docs/BRING-YOUR-OWN-AGENT.md";
|
|
10
|
+
/** The production vault's variables (`bondedVaultFromEnv` in @bondedhq/sdk). */
|
|
11
|
+
export const VAULT_ENV = ["BONDED_VAULT_ADDRESS", "BONDED_AGENT_KEY", "BONDED_ROUTER_ADDRESS"];
|
|
12
|
+
export function planInit(project, options = {}) {
|
|
13
|
+
const plan = {
|
|
14
|
+
framework: project.framework,
|
|
15
|
+
changes: [],
|
|
16
|
+
manual: [],
|
|
17
|
+
notes: [],
|
|
18
|
+
env: [],
|
|
19
|
+
install: project.hasSdk || project.framework === "python" ? [] : ["@bondedhq/sdk"],
|
|
20
|
+
};
|
|
21
|
+
const files = new Files(project, options);
|
|
22
|
+
if (project.framework === "python") {
|
|
23
|
+
pythonPlan(plan);
|
|
24
|
+
return plan;
|
|
25
|
+
}
|
|
26
|
+
// src/bonded.ts, unless the project has its own file by that name: then the first free one
|
|
27
|
+
// of bonded-agent.ts, bonded-agent-2.ts, ..., so every import below names a file init wrote.
|
|
28
|
+
const helper = files.freeHelperPath("bonded", "bonded-agent");
|
|
29
|
+
if (helper !== files.helperPath("bonded"))
|
|
30
|
+
plan.notes.push(`${files.helperPath("bonded")} is your own file, so init's helper is ${helper}; everything init adds imports from there.`);
|
|
31
|
+
const ts = project.typescript;
|
|
32
|
+
switch (project.framework) {
|
|
33
|
+
case "langchain":
|
|
34
|
+
case "ai-sdk":
|
|
35
|
+
files.write(plan, helper, toolsHelper(project.framework, ts), "the rating hook and the vault tools");
|
|
36
|
+
toolsEdit(plan, files, helper);
|
|
37
|
+
plan.notes.push("In production, take your own swap, transfer and approve tools out of the list, so every trade goes through the vault.");
|
|
38
|
+
break;
|
|
39
|
+
case "agentkit":
|
|
40
|
+
files.write(plan, helper, agentKitHelper(ts), "the rating hook and the vault wallet provider");
|
|
41
|
+
agentKitEdit(plan, files, helper);
|
|
42
|
+
break;
|
|
43
|
+
case "eliza":
|
|
44
|
+
files.write(plan, helper, elizaHelper(ts), "the rating hook and the vault plugin");
|
|
45
|
+
elizaEdit(plan, files, helper);
|
|
46
|
+
break;
|
|
47
|
+
case "mcp":
|
|
48
|
+
files.write(plan, helper, mcpHelper(ts), "the rating hook and the guarded vault");
|
|
49
|
+
mcpEdit(plan, files, helper);
|
|
50
|
+
plan.notes.push("In production, send trades through `bondedVault` (exported from the new file): `await bondedVault.swap({ tokenIn, tokenOut, amountIn })`. Each is dry-run against the vault first.");
|
|
51
|
+
break;
|
|
52
|
+
case "unknown":
|
|
53
|
+
files.write(plan, helper, unknownHelper(ts), "the rating check and the guarded vault");
|
|
54
|
+
plan.manual.push(`While Bonded rates your agent (\`isBondedEpisode()\`), read and trade through the episode's tools: the MCP server at BONDED_MCP_URL, or plain HTTP at BONDED_TOOLS_URL (see ${GUIDE}#use-your-framework).`, "In production, send trades through `bondedVault` from the new file: `await bondedVault.swap({ tokenIn, tokenOut, amountIn })`.");
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
plan.env.push(...VAULT_ENV);
|
|
58
|
+
plan.notes.push("The vault's variables (BONDED_VAULT_ADDRESS, BONDED_AGENT_KEY, BONDED_ROUTER_ADDRESS, and BONDED_VAULT_RPC_URL / BONDED_VAULT_CHAIN_ID off Robinhood Chain testnet) are read in production only. Until BONDED_VAULT_ADDRESS is set the agent runs as before; while it is rated it never touches them.");
|
|
59
|
+
if (options.url)
|
|
60
|
+
urlPlan(plan, files, project, helper);
|
|
61
|
+
return plan;
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------------------------
|
|
64
|
+
// Files: reading the project, writing new files and safe edits.
|
|
65
|
+
class Files {
|
|
66
|
+
project;
|
|
67
|
+
options;
|
|
68
|
+
pending = new Map();
|
|
69
|
+
constructor(project, options = {}) {
|
|
70
|
+
this.project = project;
|
|
71
|
+
this.options = options;
|
|
72
|
+
}
|
|
73
|
+
/** The text of a project file as it will be after the changes so far. */
|
|
74
|
+
read(path) {
|
|
75
|
+
if (this.pending.has(path))
|
|
76
|
+
return this.pending.get(path);
|
|
77
|
+
const abs = join(this.project.root, path);
|
|
78
|
+
return existsSync(abs) ? readFileSync(abs, "utf8") : undefined;
|
|
79
|
+
}
|
|
80
|
+
/** `src/bonded.ts` (or .js, or .mjs for a CommonJS package: the SDK is ESM only). */
|
|
81
|
+
helperPath(name) {
|
|
82
|
+
const ext = this.project.typescript ? "ts" : this.project.esm ? "js" : "mjs";
|
|
83
|
+
return posix.join(this.project.sourceDir, `${name}.${ext}`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The path for a new helper: `preferred` when it is free or init's own, else the first free
|
|
87
|
+
* (or init's own) of `fallback`, `fallback-2`, `fallback-3`, ...
|
|
88
|
+
*/
|
|
89
|
+
freeHelperPath(preferred, fallback) {
|
|
90
|
+
const usable = (path) => {
|
|
91
|
+
const text = this.read(path);
|
|
92
|
+
return text === undefined || text.startsWith(MARKER);
|
|
93
|
+
};
|
|
94
|
+
const first = this.helperPath(preferred);
|
|
95
|
+
if (usable(first))
|
|
96
|
+
return first;
|
|
97
|
+
for (let n = 1;; n++) {
|
|
98
|
+
const path = this.helperPath(n === 1 ? fallback : `${fallback}-${n}`);
|
|
99
|
+
if (usable(path))
|
|
100
|
+
return path;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Proposes a new file, or rewrites one `init` wrote before while it is unchanged. Someone
|
|
105
|
+
* else's file is left alone, and so is init's own once it was edited (unless
|
|
106
|
+
* --overwrite-generated): init can't tell an edit from an older template, so it keeps both.
|
|
107
|
+
*/
|
|
108
|
+
write(plan, path, text, why) {
|
|
109
|
+
const before = this.read(path);
|
|
110
|
+
if (before !== undefined && !before.startsWith(MARKER)) {
|
|
111
|
+
plan.manual.push(`${path} already exists and wasn't written by init, so it was left alone. The file init would write is printed with --print; add it under another name.`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if (before !== undefined && before !== text && !this.options.overwriteGenerated) {
|
|
115
|
+
plan.notes.push(`Kept your edited file ${path}: it differs from what init writes now. To replace it, run init again with --overwrite-generated (the diff shows what would go).`);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
this.change(plan, path, text, why);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/** Records a change to `path` (merging with earlier changes to the same file). */
|
|
122
|
+
change(plan, path, after, why) {
|
|
123
|
+
const existing = plan.changes.find((c) => c.path === path);
|
|
124
|
+
if (existing) {
|
|
125
|
+
existing.after = after;
|
|
126
|
+
if (!existing.why.includes(why))
|
|
127
|
+
existing.why += `; ${why}`;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const before = this.read(path);
|
|
131
|
+
if (before === after)
|
|
132
|
+
return;
|
|
133
|
+
plan.changes.push({ path, ...(before !== undefined ? { before } : {}), after, why });
|
|
134
|
+
}
|
|
135
|
+
this.pending.set(path, after);
|
|
136
|
+
}
|
|
137
|
+
/** Source files whose text matches, as they are now. */
|
|
138
|
+
matching(pattern) {
|
|
139
|
+
return this.project.sources
|
|
140
|
+
.map((path) => ({ path, text: this.read(path) ?? "" }))
|
|
141
|
+
.filter((f) => pattern.test(f.text));
|
|
142
|
+
}
|
|
143
|
+
/** Whether a source file can take an `import` line: TypeScript, or JS that is an ES module. */
|
|
144
|
+
isModule(path) {
|
|
145
|
+
if (/\.(ts|tsx|mts)$/.test(path))
|
|
146
|
+
return true;
|
|
147
|
+
if (/\.mjs$/.test(path))
|
|
148
|
+
return true;
|
|
149
|
+
if (/\.(js|jsx)$/.test(path))
|
|
150
|
+
return this.project.esm;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
/** The import specifier for `target` from `from`, as this project writes relative imports. */
|
|
154
|
+
specifier(from, target) {
|
|
155
|
+
let rel = posix.relative(posix.dirname(from), target);
|
|
156
|
+
if (!rel.startsWith("."))
|
|
157
|
+
rel = `./${rel}`;
|
|
158
|
+
if (/\.(ts|tsx|mts)$/.test(target))
|
|
159
|
+
return rel.replace(/\.(ts|tsx|mts)$/, this.project.importExtension);
|
|
160
|
+
return rel;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** Adds an import after the file's existing imports (or at the top), unless it is there already. */
|
|
164
|
+
export function addImport(text, statement, specifier) {
|
|
165
|
+
if (text.includes(`"${specifier}"`) || text.includes(`'${specifier}'`))
|
|
166
|
+
return text;
|
|
167
|
+
const lines = text.split("\n");
|
|
168
|
+
let insertAt = 0;
|
|
169
|
+
let i = 0;
|
|
170
|
+
if (lines[0]?.startsWith("#!"))
|
|
171
|
+
insertAt = i = 1;
|
|
172
|
+
while (i < lines.length && /^\s*["']use [a-z ]+["'];?\s*$/.test(lines[i]))
|
|
173
|
+
insertAt = ++i;
|
|
174
|
+
let inComment = false;
|
|
175
|
+
for (; i < lines.length; i++) {
|
|
176
|
+
const line = lines[i].trim();
|
|
177
|
+
if (inComment) {
|
|
178
|
+
if (line.includes("*/"))
|
|
179
|
+
inComment = false;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (line === "" || line.startsWith("//"))
|
|
183
|
+
continue;
|
|
184
|
+
if (line.startsWith("/*")) {
|
|
185
|
+
inComment = !line.includes("*/");
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!/^import[\s{*"']/.test(line))
|
|
189
|
+
break;
|
|
190
|
+
// An import, possibly over several lines: it ends at its module specifier.
|
|
191
|
+
let j = i;
|
|
192
|
+
while (j < lines.length && !/(from\s*)?["'][^"']+["']\s*;?\s*$/.test(lines[j]))
|
|
193
|
+
j++;
|
|
194
|
+
i = j;
|
|
195
|
+
insertAt = j + 1;
|
|
196
|
+
}
|
|
197
|
+
lines.splice(insertAt, 0, statement);
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Whether code on `index` may `await`: at the top level of an ES module, or inside a block opened
|
|
202
|
+
* by an `async` function. Decided from indentation, so it is conservative.
|
|
203
|
+
*/
|
|
204
|
+
function canAwait(lines, index, module) {
|
|
205
|
+
const indent = (s) => /^\s*/.exec(s)[0].length;
|
|
206
|
+
const own = indent(lines[index]);
|
|
207
|
+
if (own === 0)
|
|
208
|
+
return module;
|
|
209
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
210
|
+
const line = lines[i];
|
|
211
|
+
if (line.trim() === "")
|
|
212
|
+
continue;
|
|
213
|
+
if (indent(line) < own)
|
|
214
|
+
return /\basync\b/.test(line);
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------------------------
|
|
219
|
+
// The edits, one per framework. Each changes one line, or adds one, and only when it is sure.
|
|
220
|
+
const TOOLS_LINE = /^(\s*)(export\s+)?(const|let|var)\s+(tools)(\s*:\s*[^=]+?)?\s*=\s*(.+?);?\s*$/;
|
|
221
|
+
/** LangChain and the AI SDK: `const tools = X;` becomes `const tools = await bondedTools(X);`. */
|
|
222
|
+
function toolsEdit(plan, files, helper) {
|
|
223
|
+
const call = "const tools = await bondedTools(productionTools);";
|
|
224
|
+
const candidates = [];
|
|
225
|
+
for (const file of files.matching(/\btools\b/)) {
|
|
226
|
+
if (file.path === helper)
|
|
227
|
+
continue;
|
|
228
|
+
file.text.split("\n").forEach((line, index) => {
|
|
229
|
+
const match = TOOLS_LINE.exec(line);
|
|
230
|
+
if (match && !match[6].includes("bondedTools") && balanced(match[6]))
|
|
231
|
+
candidates.push({ path: file.path, index, match });
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const done = files.matching(/\bbondedTools\(/).filter((f) => f.path !== helper);
|
|
235
|
+
if (done.length) {
|
|
236
|
+
plan.notes.push(`${done[0].path} already calls bondedTools: no change needed there.`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const only = candidates.length === 1 ? candidates[0] : undefined;
|
|
240
|
+
if (!only || !files.isModule(only.path)) {
|
|
241
|
+
plan.manual.push(`Where your agent gets its tools, wrap them: ${call} (import { bondedTools } from the new ${helper}).${candidates.length > 1 ? ` init found ${candidates.length} \`const tools = ...\` lines and didn't guess which one.` : ""}`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const text = files.read(only.path);
|
|
245
|
+
const lines = text.split("\n");
|
|
246
|
+
if (!canAwait(lines, only.index, files.isModule(only.path))) {
|
|
247
|
+
plan.manual.push(`In ${only.path} line ${only.index + 1}, wrap the tools: ${call} The line isn't in an async function, so init didn't change it.`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const [, indent, exported, keyword, name, type, value] = only.match;
|
|
251
|
+
lines[only.index] =
|
|
252
|
+
`${indent}${exported ?? ""}${keyword} ${name}${type ?? ""} = await bondedTools(${value});`;
|
|
253
|
+
const specifier = files.specifier(only.path, helper);
|
|
254
|
+
files.change(plan, only.path, addImport(lines.join("\n"), `import { bondedTools } from "${specifier}";`, specifier), "the episode's tools while rated, the vault's tools in production");
|
|
255
|
+
}
|
|
256
|
+
/** AgentKit: `AgentKit.from(` becomes `bondedAgentKit(`. */
|
|
257
|
+
function agentKitEdit(plan, files, helper) {
|
|
258
|
+
const call = "const agentKit = await bondedAgentKit({ walletProvider, actionProviders });";
|
|
259
|
+
if (files.matching(/\bbondedAgentKit\(/).some((f) => f.path !== helper)) {
|
|
260
|
+
plan.notes.push("Your code already calls bondedAgentKit: no change needed there.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const found = files
|
|
264
|
+
.matching(/\bAgentKit\.from\(/)
|
|
265
|
+
.filter((f) => f.path !== helper)
|
|
266
|
+
.map((f) => ({ ...f, count: f.text.match(/\bAgentKit\.from\(/g).length }));
|
|
267
|
+
const only = found.length === 1 && found[0].count === 1 ? found[0] : undefined;
|
|
268
|
+
if (!only || !files.isModule(only.path)) {
|
|
269
|
+
plan.manual.push(`Build your AgentKit with bondedAgentKit instead of AgentKit.from: ${call} (import { bondedAgentKit } from the new ${helper}).`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const specifier = files.specifier(only.path, helper);
|
|
273
|
+
files.change(plan, only.path, addImport(only.text.replace(/\bAgentKit\.from\(/, "bondedAgentKit("), `import { bondedAgentKit } from "${specifier}";`, specifier), "the episode's tools while rated, every transaction through the vault in production");
|
|
274
|
+
}
|
|
275
|
+
/** ElizaOS: `...bondedPlugins` goes first in the project agent's `plugins: [`. */
|
|
276
|
+
function elizaEdit(plan, files, helper) {
|
|
277
|
+
const line = "plugins: [...bondedPlugins, ...yourPlugins],";
|
|
278
|
+
if (files.matching(/\bbondedPlugins\b/).some((f) => f.path !== helper)) {
|
|
279
|
+
plan.notes.push("Your code already uses bondedPlugins: no change needed there.");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const agents = files
|
|
283
|
+
.matching(/\bProjectAgent\b/)
|
|
284
|
+
.filter((f) => f.path !== helper)
|
|
285
|
+
.map((f) => ({ ...f, count: f.text.match(/^\s*plugins\s*:\s*\[/gm)?.length ?? 0 }));
|
|
286
|
+
const only = agents.length === 1 && agents[0].count === 1 ? agents[0] : undefined;
|
|
287
|
+
if (!only || !files.isModule(only.path)) {
|
|
288
|
+
plan.manual.push(`Add Bonded's plugin to your agent's plugins (the ProjectAgent's, or the plugins you pass the runtime; not the character's plugin names): ${line} (import { bondedPlugins } from the new ${helper}).`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const specifier = files.specifier(only.path, helper);
|
|
292
|
+
const lines = only.text.split("\n");
|
|
293
|
+
const index = lines.findIndex((l) => /^\s*plugins\s*:\s*\[/.test(l));
|
|
294
|
+
const [, open, rest] = /^(\s*plugins\s*:\s*\[)(.*)$/.exec(lines[index]);
|
|
295
|
+
if (rest.trim() === "") {
|
|
296
|
+
// The list continues on the next lines: Bonded's goes first, on its own line.
|
|
297
|
+
const indent = /^\s*/.exec(lines[index + 1] ?? "")[0] || `${/^\s*/.exec(open)[0]} `;
|
|
298
|
+
lines.splice(index + 1, 0, `${indent}...bondedPlugins,`);
|
|
299
|
+
}
|
|
300
|
+
else
|
|
301
|
+
lines[index] = rest.trimStart().startsWith("]")
|
|
302
|
+
? `${open}...bondedPlugins${rest.trimStart()}`
|
|
303
|
+
: `${open}...bondedPlugins, ${rest.trimStart()}`;
|
|
304
|
+
files.change(plan, only.path, addImport(lines.join("\n"), `import { bondedPlugins } from "${specifier}";`, specifier), "the episode's tools while rated, vault actions in production");
|
|
305
|
+
}
|
|
306
|
+
/** A plain MCP client: `new URL(X)` in its HTTP transport becomes `new URL(bondedMcpUrl(X))`. */
|
|
307
|
+
function mcpEdit(plan, files, helper) {
|
|
308
|
+
const transport = /new (StreamableHTTPClientTransport|SSEClientTransport)\(\s*new URL\(/;
|
|
309
|
+
const call = "new StreamableHTTPClientTransport(new URL(bondedMcpUrl(yourServerUrl)))";
|
|
310
|
+
if (files.matching(/\bbondedMcpUrl\(/).some((f) => f.path !== helper)) {
|
|
311
|
+
plan.notes.push("Your code already uses bondedMcpUrl: no change needed there.");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const found = files.matching(transport).filter((f) => f.path !== helper);
|
|
315
|
+
const only = found.length === 1 ? found[0] : undefined;
|
|
316
|
+
const lines = only?.text.split("\n") ?? [];
|
|
317
|
+
const index = lines.findIndex((l) => transport.test(l));
|
|
318
|
+
const edited = index >= 0 ? wrapUrlArgument(lines[index]) : undefined;
|
|
319
|
+
if (!only ||
|
|
320
|
+
!files.isModule(only.path) ||
|
|
321
|
+
!edited ||
|
|
322
|
+
lines.filter((l) => transport.test(l)).length !== 1) {
|
|
323
|
+
plan.manual.push(`Point your MCP client at the episode while Bonded rates it: ${call} (import { bondedMcpUrl } from the new ${helper}).`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
lines[index] = edited;
|
|
327
|
+
const specifier = files.specifier(only.path, helper);
|
|
328
|
+
files.change(plan, only.path, addImport(lines.join("\n"), `import { bondedMcpUrl } from "${specifier}";`, specifier), "the episode's MCP server while rated");
|
|
329
|
+
}
|
|
330
|
+
/** `...new URL(X)...` to `...new URL(bondedMcpUrl(X))...`, when X's parentheses close on the line. */
|
|
331
|
+
function wrapUrlArgument(line) {
|
|
332
|
+
const open = line.search(/new URL\(/) + "new URL(".length;
|
|
333
|
+
let depth = 1;
|
|
334
|
+
for (let i = open; i < line.length; i++) {
|
|
335
|
+
if (line[i] === "(")
|
|
336
|
+
depth++;
|
|
337
|
+
else if (line[i] === ")" && --depth === 0) {
|
|
338
|
+
const arg = line.slice(open, i).trim();
|
|
339
|
+
if (!arg || arg.includes(","))
|
|
340
|
+
return undefined;
|
|
341
|
+
return `${line.slice(0, open)}bondedMcpUrl(${arg})${line.slice(i)}`;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
/** Brackets and quotes in a one-line expression are closed (so it doesn't continue below). */
|
|
347
|
+
function balanced(expression) {
|
|
348
|
+
let depth = 0;
|
|
349
|
+
let quote;
|
|
350
|
+
for (const ch of expression) {
|
|
351
|
+
if (quote) {
|
|
352
|
+
if (ch === quote)
|
|
353
|
+
quote = undefined;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (ch === '"' || ch === "'" || ch === "`")
|
|
357
|
+
quote = ch;
|
|
358
|
+
else if ("([{".includes(ch))
|
|
359
|
+
depth++;
|
|
360
|
+
else if (")]}".includes(ch) && --depth < 0)
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
return depth === 0 && quote === undefined && !/[,=+\-*/&|?:]\s*$/.test(expression);
|
|
364
|
+
}
|
|
365
|
+
// ---------------------------------------------------------------------------------------------
|
|
366
|
+
// Agent URL routes.
|
|
367
|
+
function urlPlan(plan, files, project, helper) {
|
|
368
|
+
const ts = project.typescript;
|
|
369
|
+
const handlerFile = files.freeHelperPath("bonded-agent-url", "bonded-agent-url");
|
|
370
|
+
files.write(plan, handlerFile, agentUrlHandler(project, handlerFile, files.specifier(handlerFile, helper), ts), "the Agent URL handler");
|
|
371
|
+
plan.env.push("BONDED_AGENT_SECRET");
|
|
372
|
+
const route = (path, text, why) => files.write(plan, path, text, why);
|
|
373
|
+
let endpoint = "/bonded";
|
|
374
|
+
switch (project.web) {
|
|
375
|
+
case "next-app": {
|
|
376
|
+
const path = `${project.nextAppDir}/api/bonded/route.${ts ? "ts" : "js"}`;
|
|
377
|
+
route(path, nextAppRoute(files.specifier(path, handlerFile), project.nextMajor ?? 0, ts), "the Next.js route");
|
|
378
|
+
endpoint = "/api/bonded";
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
case "next-pages": {
|
|
382
|
+
const pages = existsSync(join(project.root, "src/pages")) ? "src/pages" : "pages";
|
|
383
|
+
const path = `${pages}/api/bonded.${ts ? "ts" : "js"}`;
|
|
384
|
+
route(path, nextPagesRoute(files.specifier(path, handlerFile)), "the Next.js API route");
|
|
385
|
+
endpoint = "/api/bonded";
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "express":
|
|
389
|
+
case "hono":
|
|
390
|
+
mountRoute(plan, files, project.web, handlerFile);
|
|
391
|
+
break;
|
|
392
|
+
case "node-http": {
|
|
393
|
+
const path = files.helperPath("bonded-server");
|
|
394
|
+
route(path, nodeServer(files.specifier(path, handlerFile)), "a small server for the Agent URL");
|
|
395
|
+
plan.env.push("BONDED_AGENT_PORT");
|
|
396
|
+
plan.manual.push(`Run ${path} next to your agent (it listens on BONDED_AGENT_PORT, default 8787) and put it behind HTTPS.`);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
plan.manual.push(`Fill in onEpisode in ${handlerFile}: run your agent on episode.task.goal with the episode's tools.`);
|
|
401
|
+
plan.notes.push(`Next: deploy, then on your agent's page set the Agent URL to https://<your host>${endpoint}, put the agent secret Bonded shows you in BONDED_AGENT_SECRET, press Verify, then run a connection check.`);
|
|
402
|
+
}
|
|
403
|
+
const MOUNT = {
|
|
404
|
+
express: {
|
|
405
|
+
app: /^(\s*)(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*express\(\s*\)\s*;?\s*$/,
|
|
406
|
+
line: (app) => `${app}.post("/bonded", bondedNodeHandler); // Bonded's Agent URL: before any body parser`,
|
|
407
|
+
name: "bondedNodeHandler",
|
|
408
|
+
},
|
|
409
|
+
hono: {
|
|
410
|
+
app: /^(\s*)(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*new Hono(?:<[^>]*>)?\(\s*\)\s*;?\s*$/,
|
|
411
|
+
line: (app) => `${app}.post("/bonded", (c) => bondedHandler(c.req.raw)); // Bonded's Agent URL`,
|
|
412
|
+
name: "bondedHandler",
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
/** Express or Hono: one route line right after `const app = express()` / `new Hono()`. */
|
|
416
|
+
function mountRoute(plan, files, web, handlerFile) {
|
|
417
|
+
const m = MOUNT[web];
|
|
418
|
+
const found = files
|
|
419
|
+
.matching(/./)
|
|
420
|
+
.filter((f) => f.path !== handlerFile)
|
|
421
|
+
.flatMap((f) => f.text.split("\n").flatMap((line, index) => {
|
|
422
|
+
const match = m.app.exec(line);
|
|
423
|
+
return match ? [{ ...f, index, indent: match[1], app: match[2] }] : [];
|
|
424
|
+
}));
|
|
425
|
+
const only = found.length === 1 ? found[0] : undefined;
|
|
426
|
+
if (!only || !files.isModule(only.path) || only.text.includes(m.name)) {
|
|
427
|
+
if (only?.text.includes(m.name))
|
|
428
|
+
return;
|
|
429
|
+
plan.manual.push(`Mount the Agent URL route on your app: ${m.line("app")} (import { ${m.name} } from the new ${handlerFile}).`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const lines = only.text.split("\n");
|
|
433
|
+
lines.splice(only.index + 1, 0, `${only.indent}${m.line(only.app)}`);
|
|
434
|
+
const specifier = files.specifier(only.path, handlerFile);
|
|
435
|
+
files.change(plan, only.path, addImport(lines.join("\n"), `import { ${m.name} } from "${specifier}";`, specifier), "the Agent URL route");
|
|
436
|
+
}
|
|
437
|
+
// ---------------------------------------------------------------------------------------------
|
|
438
|
+
// Templates. Each new file starts with MARKER.
|
|
439
|
+
const header = (what) => `${MARKER}: ${what}. Edit it freely.\n// Guide: ${GUIDE}\n`;
|
|
440
|
+
/** Above every helper's production branch: why it is never reached while rated. */
|
|
441
|
+
const RATED_COMMENT = "// While Bonded rates the agent, the production vault is never built, even if a .env file put its\n// variables back: rated trades go through the episode's tools only.\n";
|
|
442
|
+
function toolsHelper(framework, ts) {
|
|
443
|
+
const lc = framework === "langchain";
|
|
444
|
+
const sub = lc ? "langchain" : "ai-sdk";
|
|
445
|
+
const episodeFn = lc ? "bondedLangChainTools" : "bondedAiSdkTools";
|
|
446
|
+
const vaultFn = lc ? "bondedVaultLangChainTools" : "bondedVaultAiSdkTools";
|
|
447
|
+
const sig = ts
|
|
448
|
+
? lc
|
|
449
|
+
? "export async function bondedTools<T>(productionTools: T[], episode?: BondedEpisode): Promise<T[]> {"
|
|
450
|
+
: "export async function bondedTools<T extends object>(\n productionTools: T,\n episode?: BondedEpisode,\n): Promise<T> {"
|
|
451
|
+
: "export async function bondedTools(productionTools, episode) {";
|
|
452
|
+
const asT = ts ? (lc ? " as unknown as T[]" : " as unknown as T") : "";
|
|
453
|
+
const combine = lc
|
|
454
|
+
? `[...productionTools, ...(${vaultFn}({ vault })${asT})]`
|
|
455
|
+
: `{ ...productionTools, ...${vaultFn}({ vault }) }${ts ? " as T" : ""}`;
|
|
456
|
+
return `${header("Bonded's rating hook and Mandate Vault tools")}import { ${ts ? "type BondedEpisode, " : ""}bondedVaultFromEnv, isBondedEpisode } from "@bondedhq/sdk";
|
|
457
|
+
import { ${episodeFn}, ${vaultFn} } from "@bondedhq/sdk/${sub}";
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Your agent's tools, by where it runs:
|
|
461
|
+
* - while Bonded rates it (under the runner, or with the \`episode\` an Agent URL handler gets):
|
|
462
|
+
* the episode's tools, so it is rated with the code it runs in production;
|
|
463
|
+
* - in production with BONDED_VAULT_ADDRESS set: your tools plus swap, transfer, approve and
|
|
464
|
+
* vault_status through your Mandate Vault, each dry-run first and refused on a predicted breach;
|
|
465
|
+
* - otherwise: your tools, unchanged.
|
|
466
|
+
*/
|
|
467
|
+
${sig}
|
|
468
|
+
if (episode || isBondedEpisode()) return (await ${episodeFn}(episode))${asT};
|
|
469
|
+
const vault = bondedVaultFromEnv();
|
|
470
|
+
if (!vault) return productionTools;
|
|
471
|
+
return ${combine};
|
|
472
|
+
}
|
|
473
|
+
`;
|
|
474
|
+
}
|
|
475
|
+
function agentKitHelper(ts) {
|
|
476
|
+
const sig = ts
|
|
477
|
+
? "export async function bondedAgentKit(\n options: NonNullable<Parameters<typeof AgentKit.from>[0]>,\n episode?: BondedEpisode,\n): Promise<AgentKit> {"
|
|
478
|
+
: "export async function bondedAgentKit(options, episode) {";
|
|
479
|
+
return `${header("Bonded's rating hook and Mandate Vault wallet provider")}import { AgentKit, EvmWalletProvider } from "@coinbase/agentkit";
|
|
480
|
+
import { ${ts ? "type BondedEpisode, " : ""}isBondedEpisode } from "@bondedhq/sdk";
|
|
481
|
+
import { BondedWalletProvider, bondedEpisodeActionProvider } from "@bondedhq/sdk/agentkit";
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* \`AgentKit.from\`, with Bonded:
|
|
485
|
+
* - while Bonded rates the agent (under the runner, or with the \`episode\` an Agent URL handler
|
|
486
|
+
* gets): the episode's tools are its only actions, so it is rated with the code it runs;
|
|
487
|
+
* - in production with BONDED_VAULT_ADDRESS set: your wallet provider signs as the vault's agent
|
|
488
|
+
* key and every transaction becomes vault.execute(to, data), dry-run first;
|
|
489
|
+
* - otherwise: AgentKit.from(options), unchanged.
|
|
490
|
+
*/
|
|
491
|
+
${sig}
|
|
492
|
+
if (episode || isBondedEpisode())
|
|
493
|
+
return AgentKit.from({ ...options, actionProviders: [await bondedEpisodeActionProvider(episode)] });
|
|
494
|
+
const vault = process.env.BONDED_VAULT_ADDRESS;
|
|
495
|
+
if (!vault || !(options.walletProvider instanceof EvmWalletProvider)) return AgentKit.from(options);
|
|
496
|
+
const walletProvider = await BondedWalletProvider.create({
|
|
497
|
+
wallet: options.walletProvider,
|
|
498
|
+
vault: vault${ts ? " as `0x${string}`" : ""},
|
|
499
|
+
guard: true,
|
|
500
|
+
});
|
|
501
|
+
return AgentKit.from({ ...options, walletProvider });
|
|
502
|
+
}
|
|
503
|
+
`;
|
|
504
|
+
}
|
|
505
|
+
function elizaHelper(ts) {
|
|
506
|
+
void ts;
|
|
507
|
+
return `${header("Bonded's rating hook and Mandate Vault plugin")}import { bondedVaultFromEnv, isBondedEpisode } from "@bondedhq/sdk";
|
|
508
|
+
import { bondedEpisodePlugin, bondedPlugin } from "@bondedhq/sdk/eliza";
|
|
509
|
+
|
|
510
|
+
${RATED_COMMENT}const rated = isBondedEpisode();
|
|
511
|
+
const vault = rated ? undefined : bondedVaultFromEnv();
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Bonded's plugin, for your agent's plugins:
|
|
515
|
+
* - while Bonded rates the agent: the episode's tools;
|
|
516
|
+
* - in production with BONDED_VAULT_ADDRESS set: BONDED_SWAP, BONDED_TRANSFER and BONDED_APPROVE
|
|
517
|
+
* through your Mandate Vault (each dry-run first) and the BONDED_VAULT provider;
|
|
518
|
+
* - otherwise: nothing.
|
|
519
|
+
*/
|
|
520
|
+
export const bondedPlugins = rated
|
|
521
|
+
? [bondedEpisodePlugin()]
|
|
522
|
+
: vault
|
|
523
|
+
? [bondedPlugin({ vault })]
|
|
524
|
+
: [];
|
|
525
|
+
`;
|
|
526
|
+
}
|
|
527
|
+
function mcpHelper(ts) {
|
|
528
|
+
return `${header("Bonded's rating hook and guarded Mandate Vault")}import { bondedVaultFromEnv, guarded, isBondedEpisode } from "@bondedhq/sdk";
|
|
529
|
+
|
|
530
|
+
/** The MCP server to use: the episode's while Bonded rates the agent, yours otherwise. */
|
|
531
|
+
export function bondedMcpUrl(productionUrl${ts ? ": string | URL" : ""})${ts ? ": string | URL" : ""} {
|
|
532
|
+
return isBondedEpisode() ? process.env.BONDED_MCP_URL${ts ? "!" : ""} : productionUrl;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
${RATED_COMMENT}const vault = isBondedEpisode() ? undefined : bondedVaultFromEnv();
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* In production with BONDED_VAULT_ADDRESS set: your Mandate Vault, every write dry-run first and
|
|
539
|
+
* refused on a predicted breach. Trade with \`await bondedVault.swap({ tokenIn, tokenOut, amountIn })\`.
|
|
540
|
+
* Undefined while Bonded rates the agent.
|
|
541
|
+
*/
|
|
542
|
+
export const bondedVault = vault ? guarded(vault) : undefined;
|
|
543
|
+
`;
|
|
544
|
+
}
|
|
545
|
+
function unknownHelper(ts) {
|
|
546
|
+
void ts;
|
|
547
|
+
return `${header("Bonded's rating check and guarded Mandate Vault")}import { bondedVaultFromEnv, guarded, isBondedEpisode } from "@bondedhq/sdk";
|
|
548
|
+
|
|
549
|
+
/** True while Bonded rates the agent: use the episode's tools (BONDED_MCP_URL, BONDED_TOOLS_URL) then. */
|
|
550
|
+
export const rated = isBondedEpisode();
|
|
551
|
+
|
|
552
|
+
${RATED_COMMENT}const vault = rated ? undefined : bondedVaultFromEnv();
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* In production with BONDED_VAULT_ADDRESS set: your Mandate Vault, every write dry-run first and
|
|
556
|
+
* refused on a predicted breach. Trade with \`await bondedVault.swap({ tokenIn, tokenOut, amountIn })\`.
|
|
557
|
+
* Undefined while Bonded rates the agent.
|
|
558
|
+
*/
|
|
559
|
+
export const bondedVault = vault ? guarded(vault) : undefined;
|
|
560
|
+
`;
|
|
561
|
+
}
|
|
562
|
+
const EPISODE_HINT = {
|
|
563
|
+
langchain: [
|
|
564
|
+
"const tools = await bondedTools(productionTools, episode); // from HELPER",
|
|
565
|
+
"const agent = createReactAgent({ llm, tools }); await agent.invoke(...);",
|
|
566
|
+
],
|
|
567
|
+
"ai-sdk": [
|
|
568
|
+
"const tools = await bondedTools(productionTools, episode); // from HELPER",
|
|
569
|
+
"const result = await generateText({ model, tools, prompt: episode.task.goal, abortSignal: signal });",
|
|
570
|
+
],
|
|
571
|
+
agentkit: [
|
|
572
|
+
"const kit = await bondedAgentKit({ walletProvider, actionProviders }, episode); // from HELPER",
|
|
573
|
+
],
|
|
574
|
+
eliza: [
|
|
575
|
+
"bondedEpisodePlugin({ episode }) from @bondedhq/sdk/eliza is the runtime's plugin for it",
|
|
576
|
+
],
|
|
577
|
+
mcp: ["const tools = await connectEpisodeTools(episode); // from @bondedhq/sdk/mcp"],
|
|
578
|
+
python: [],
|
|
579
|
+
unknown: ["const tools = await connectEpisodeTools(episode); // from @bondedhq/sdk/mcp"],
|
|
580
|
+
};
|
|
581
|
+
function agentUrlHandler(project, path, helper, ts) {
|
|
582
|
+
return `${header("Bonded's Agent URL handler")}import { ${ts ? "type BondedHandler, " : ""}createBondedHandler } from "@bondedhq/sdk/agent-url";
|
|
583
|
+
|
|
584
|
+
let handler${ts ? ": BondedHandler | undefined" : ""};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Bonded calls this once per episode, signed with your agent secret (BONDED_AGENT_SECRET). It
|
|
588
|
+
* answers Bonded's verify challenge, acknowledges an episode at once and runs onEpisode in the
|
|
589
|
+
* background, and posts /done when onEpisode resolves.
|
|
590
|
+
*/
|
|
591
|
+
export const bondedHandler${ts ? ": BondedHandler" : ""} = (request, context) => {
|
|
592
|
+
handler ??= createBondedHandler({
|
|
593
|
+
secret: process.env.BONDED_AGENT_SECRET ?? "",
|
|
594
|
+
...(process.env.GIT_SHA ? { version: process.env.GIT_SHA } : {}),
|
|
595
|
+
async onEpisode(episode, { signal }) {
|
|
596
|
+
// Run your agent on episode.task.goal with the episode's tools, and stop when signal aborts:
|
|
597
|
+
${EPISODE_HINT[project.framework].map((l) => ` // ${l.replace("HELPER", helper)}\n`).join("")} // Return { finalMessage, usage } when it has finished.
|
|
598
|
+
void signal;
|
|
599
|
+
throw new Error("Connect your agent in ${path} (onEpisode) to be rated over its Agent URL");
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
return handler(request, context);
|
|
603
|
+
};
|
|
604
|
+
`;
|
|
605
|
+
}
|
|
606
|
+
function nextAppRoute(specifier, nextMajor, ts) {
|
|
607
|
+
const after = nextMajor >= 15;
|
|
608
|
+
return `${header("Bonded's Agent URL route")}${after ? 'import { after } from "next/server";\n' : ""}import { bondedHandler } from "${specifier}";
|
|
609
|
+
|
|
610
|
+
export const runtime = "nodejs";
|
|
611
|
+
export const dynamic = "force-dynamic";
|
|
612
|
+
|
|
613
|
+
export function POST(request${ts ? ": Request" : ""}) {
|
|
614
|
+
return bondedHandler(request${after ? ", { waitUntil: (work) => after(work) }" : ""});
|
|
615
|
+
}
|
|
616
|
+
`;
|
|
617
|
+
}
|
|
618
|
+
function nextPagesRoute(specifier) {
|
|
619
|
+
return `${header("Bonded's Agent URL route")}import { toNodeHandler } from "@bondedhq/sdk/agent-url";
|
|
620
|
+
import { bondedHandler } from "${specifier}";
|
|
621
|
+
|
|
622
|
+
// The signature covers the raw body: Next.js must not parse it.
|
|
623
|
+
export const config = { api: { bodyParser: false } };
|
|
624
|
+
|
|
625
|
+
export default toNodeHandler(bondedHandler);
|
|
626
|
+
`;
|
|
627
|
+
}
|
|
628
|
+
function nodeServer(specifier) {
|
|
629
|
+
return `${header("a small server for Bonded's Agent URL")}import { createServer } from "node:http";
|
|
630
|
+
import { toNodeHandler } from "@bondedhq/sdk/agent-url";
|
|
631
|
+
import { bondedHandler } from "${specifier}";
|
|
632
|
+
|
|
633
|
+
const port = Number(process.env.BONDED_AGENT_PORT ?? 8787);
|
|
634
|
+
createServer(toNodeHandler(bondedHandler)).listen(port, () =>
|
|
635
|
+
console.log(\`Bonded Agent URL listening on http://localhost:\${port}/\`),
|
|
636
|
+
);
|
|
637
|
+
`;
|
|
638
|
+
}
|
|
639
|
+
function pythonPlan(plan) {
|
|
640
|
+
plan.manual.push("Python: there is no Bonded package to install. While Bonded rates the agent (BONDED_EPISODE_ID is set), call the episode's tools over plain HTTP:", [
|
|
641
|
+
" import json, os, urllib.request",
|
|
642
|
+
" def call(name, args=None):",
|
|
643
|
+
" url = f\"{os.environ['BONDED_TOOLS_URL'].rstrip('/')}/{name}\"",
|
|
644
|
+
' req = urllib.request.Request(url, data=json.dumps(args or {}).encode(), method="POST", headers={"content-type": "application/json"})',
|
|
645
|
+
" return json.loads(urllib.request.urlopen(req).read())",
|
|
646
|
+
].join("\n"), `In production, every trade is MandateVault.execute(target, data) sent from the vault's agent key (web3.py works). For an Agent URL, see https://github.com/ddboy19912/bonded/tree/main/examples/python-agent-url.`);
|
|
647
|
+
}
|
|
648
|
+
/** What the plan does, framework by framework, in a line: for the summary. */
|
|
649
|
+
export function frameworkLine(project) {
|
|
650
|
+
return `${FRAMEWORK_NAMES[project.framework]} (${project.evidence})`;
|
|
651
|
+
}
|
|
652
|
+
//# sourceMappingURL=plan.js.map
|