@coinrithm/mcp-trading 0.1.7 → 0.2.0

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.
@@ -0,0 +1,164 @@
1
+ // Strict key/enum lint over the RESOLVED frontmatter — the fix for the
2
+ // "silently coerced to a default" footgun (Codex audit #9). A typo like
3
+ // `maxLevrage: 3` must NOT silently become the default; it must be surfaced
4
+ // (with a "did you mean" suggestion). The runner uses this in hosted mode to
5
+ // fail closed; self-host can treat the issues as warnings.
6
+ import { PROVIDERS, VENUES, ALLOWED_CAPABILITIES, OBJECTIVE_PRIMARIES, } from "./types.js";
7
+ // Allowed keys per block. `null` = free-form (sizing is soft guidance; its
8
+ // enforced-key ban is handled in the resolver, not here).
9
+ const ALLOWED_KEYS = {
10
+ $root: [
11
+ "name",
12
+ "description",
13
+ "spec",
14
+ "mode",
15
+ "trigger",
16
+ "model",
17
+ "venues",
18
+ "risk",
19
+ "sizing",
20
+ "limits",
21
+ "abstention",
22
+ "sync",
23
+ "killSwitch",
24
+ "objective",
25
+ "capabilities",
26
+ "include",
27
+ "watchlist",
28
+ ],
29
+ trigger: ["cadence", "timezone", "events"],
30
+ model: ["provider", "name", "baseUrl"],
31
+ risk: [
32
+ "maxLeverage",
33
+ "perTradeMarginMusd",
34
+ "maxConcurrentPositions",
35
+ "requireStopLoss",
36
+ "watchlist",
37
+ ],
38
+ sizing: null,
39
+ limits: [
40
+ "maxTradesPerDay",
41
+ "maxWritesPerCycle",
42
+ "maxDailyLossMusd",
43
+ "maxOpenMarginMusd",
44
+ ],
45
+ abstention: [
46
+ "onStaleData",
47
+ "onWeakSignal",
48
+ "onMissingQuote",
49
+ "onInsufficientBalance",
50
+ "minConfidence",
51
+ ],
52
+ sync: ["requirePollBeforeWrite"],
53
+ killSwitch: [
54
+ "maxDrawdownMusd",
55
+ "maxConsecutiveRejects",
56
+ "maxConsecutiveModelFailures",
57
+ "onRateLimitPressure",
58
+ ],
59
+ objective: ["primary", "secondary", "horizon"],
60
+ };
61
+ function levenshtein(a, b) {
62
+ const m = a.length;
63
+ const n = b.length;
64
+ const d = Array.from({ length: n + 1 }, (_, i) => i);
65
+ for (let i = 1; i <= m; i++) {
66
+ let prev = d[0];
67
+ d[0] = i;
68
+ for (let j = 1; j <= n; j++) {
69
+ const tmp = d[j];
70
+ d[j] = Math.min(d[j] + 1, d[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
71
+ prev = tmp;
72
+ }
73
+ }
74
+ return d[n];
75
+ }
76
+ function suggest(key, allowed) {
77
+ let best = null;
78
+ let bestDist = 3; // only suggest within edit distance 2
79
+ for (const cand of allowed) {
80
+ const dist = levenshtein(key.toLowerCase(), cand.toLowerCase());
81
+ if (dist < bestDist) {
82
+ bestDist = dist;
83
+ best = cand;
84
+ }
85
+ }
86
+ return best;
87
+ }
88
+ const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
89
+ function lintKeys(block, obj, issues) {
90
+ const allowed = ALLOWED_KEYS[block];
91
+ if (!allowed)
92
+ return; // free-form (null) or unknown block
93
+ for (const k of Object.keys(obj)) {
94
+ if (!allowed.includes(k)) {
95
+ const s = suggest(k, allowed);
96
+ issues.push({
97
+ code: "unknown_key",
98
+ path: block === "$root" ? k : `${block}.${k}`,
99
+ message: `unknown key "${k}"${s ? ` — did you mean "${s}"?` : ""}`,
100
+ });
101
+ }
102
+ }
103
+ }
104
+ export function strictLint(raw) {
105
+ const issues = [];
106
+ lintKeys("$root", raw, issues);
107
+ for (const block of [
108
+ "trigger",
109
+ "model",
110
+ "risk",
111
+ "limits",
112
+ "abstention",
113
+ "sync",
114
+ "killSwitch",
115
+ "objective",
116
+ ]) {
117
+ if (isObj(raw[block]))
118
+ lintKeys(block, raw[block], issues);
119
+ }
120
+ // Enum checks.
121
+ const model = raw.model;
122
+ if (isObj(model) &&
123
+ typeof model.provider === "string" &&
124
+ !PROVIDERS.includes(model.provider)) {
125
+ issues.push({
126
+ code: "bad_enum",
127
+ path: "model.provider",
128
+ message: `model.provider "${model.provider}" is not one of: ${PROVIDERS.join(", ")}`,
129
+ });
130
+ }
131
+ if (Array.isArray(raw.venues)) {
132
+ for (const v of raw.venues) {
133
+ if (!VENUES.includes(v)) {
134
+ issues.push({
135
+ code: "bad_enum",
136
+ path: "venues",
137
+ message: `unknown venue "${String(v)}" (allowed: ${VENUES.join(", ")})`,
138
+ });
139
+ }
140
+ }
141
+ }
142
+ if (Array.isArray(raw.capabilities)) {
143
+ for (const c of raw.capabilities) {
144
+ if (!ALLOWED_CAPABILITIES.includes(c)) {
145
+ issues.push({
146
+ code: "bad_enum",
147
+ path: "capabilities",
148
+ message: `unknown capability "${String(c)}" (allowed: ${ALLOWED_CAPABILITIES.join(", ")})`,
149
+ });
150
+ }
151
+ }
152
+ }
153
+ const objective = raw.objective;
154
+ if (isObj(objective) &&
155
+ typeof objective.primary === "string" &&
156
+ !OBJECTIVE_PRIMARIES.includes(objective.primary)) {
157
+ issues.push({
158
+ code: "bad_enum",
159
+ path: "objective.primary",
160
+ message: `objective.primary "${objective.primary}" is not one of: ${OBJECTIVE_PRIMARIES.join(", ")}`,
161
+ });
162
+ }
163
+ return issues;
164
+ }
@@ -0,0 +1,192 @@
1
+ // Agent templates + risk presets for the scaffolder.
2
+ //
3
+ // A `new` agent is a folder-of-one (a single agent.md) that ALWAYS emits the
4
+ // hosted-required blocks (limits / abstention / sync / killSwitch) so it passes
5
+ // `validate --hosted` out of the box. Presets differ only within safe ranges:
6
+ // every preset is paper-only, keeps requireStopLoss=true, the kill-switch on,
7
+ // and stays under the server caps (leverage <= 20). "bold" is faster + larger
8
+ // but still safe.
9
+ import { stringify as stringifyYaml } from "yaml";
10
+ import { COINRITHM_API } from "./version.js";
11
+ export const PRESET_NAMES = ["conservative", "balanced", "bold"];
12
+ const PRESETS = {
13
+ conservative: {
14
+ leverage: 2,
15
+ margin: 50,
16
+ maxPos: 2,
17
+ tradesDay: 6,
18
+ writesCycle: 1,
19
+ dailyLoss: 300,
20
+ openMargin: 600,
21
+ minConf: 0.6,
22
+ cadence: "4h",
23
+ drawdown: 300,
24
+ modelFail: 3,
25
+ },
26
+ balanced: {
27
+ leverage: 3,
28
+ margin: 100,
29
+ maxPos: 3,
30
+ tradesDay: 12,
31
+ writesCycle: 2,
32
+ dailyLoss: 750,
33
+ openMargin: 2000,
34
+ minConf: 0.55,
35
+ cadence: "1h",
36
+ drawdown: 750,
37
+ modelFail: 4,
38
+ },
39
+ bold: {
40
+ leverage: 5,
41
+ margin: 250,
42
+ maxPos: 5,
43
+ tradesDay: 24,
44
+ writesCycle: 3,
45
+ dailyLoss: 2000,
46
+ openMargin: 5000,
47
+ minConf: 0.5,
48
+ cadence: "1h",
49
+ drawdown: 2000,
50
+ modelFail: 5,
51
+ },
52
+ };
53
+ const THESIS_BODY = `# Momentum Futures — strategy
54
+
55
+ You operate a CoinRithm **paper-trading** futures account (50,000 virtual mUSD).
56
+ Everything here is simulated; it is not financial advice and never touches real
57
+ money. Edit this prose freely (any language) — it is your agent's borders.
58
+
59
+ ## Each cycle
60
+
61
+ 1. Ground yourself: read your portfolio and open positions first. Never assume
62
+ balances or what is already open.
63
+ 2. Scan the watchlist. A candidate is a coin whose short and medium momentum
64
+ agree (both up, or both down) and is not already an open position.
65
+ 3. Pick at most one strongest candidate. If nothing is clean, skip — a skipped
66
+ cycle is cheaper than a forced trade.
67
+ 4. Quote before you open. Read the liquidation price and confirm it is sane. If
68
+ the quote is not eligible, relay the reason and stop.
69
+ 5. Open small and protected: enter in the trend direction and set a stop-loss at
70
+ open. Place the take-profit a touch wider than the stop.
71
+ 6. Stay in sync: poll your trades for any stop / take-profit / liquidation that
72
+ fired while you were not looking, and react to what actually happened.
73
+
74
+ The hard caps (leverage, margin, watchlist) live in the config blocks above and
75
+ are enforced by the runner — change them there, not in this prose.`;
76
+ const PERSONA_STUB = `# Persona
77
+
78
+ Patient and selective. Prefers to skip rather than force a marginal trade.
79
+ States its reasoning plainly and never frames paper results as real-money advice.
80
+ `;
81
+ export function buildAgentObject(name, preset) {
82
+ const p = PRESETS[preset];
83
+ const frontmatter = {
84
+ spec: "coinrithm.agent.v1",
85
+ name,
86
+ description: `Trend-following CoinRithm paper-trading agent (momentum-futures, ${preset}).`,
87
+ trigger: { cadence: p.cadence, timezone: "UTC" },
88
+ model: { provider: "anthropic", name: "claude-sonnet-4-6" },
89
+ venues: ["futures"],
90
+ risk: {
91
+ maxLeverage: p.leverage,
92
+ perTradeMarginMusd: p.margin,
93
+ maxConcurrentPositions: p.maxPos,
94
+ requireStopLoss: true,
95
+ watchlist: ["BTC", "ETH", "SOL"],
96
+ },
97
+ sizing: { riskRewardMin: 1.8, riskPerTradePct: 1, kellyFraction: 0.25 },
98
+ limits: {
99
+ maxTradesPerDay: p.tradesDay,
100
+ maxWritesPerCycle: p.writesCycle,
101
+ maxDailyLossMusd: p.dailyLoss,
102
+ maxOpenMarginMusd: p.openMargin,
103
+ },
104
+ abstention: {
105
+ onStaleData: true,
106
+ onWeakSignal: true,
107
+ onMissingQuote: true,
108
+ onInsufficientBalance: true,
109
+ minConfidence: p.minConf,
110
+ },
111
+ sync: { requirePollBeforeWrite: true },
112
+ killSwitch: {
113
+ maxDrawdownMusd: p.drawdown,
114
+ maxConsecutiveRejects: 0,
115
+ maxConsecutiveModelFailures: p.modelFail,
116
+ onRateLimitPressure: true,
117
+ },
118
+ objective: {
119
+ primary: "realized_pnl",
120
+ secondary: ["drawdown_control", "evidence_completeness"],
121
+ horizon: "7d",
122
+ },
123
+ };
124
+ return { frontmatter, body: THESIS_BODY };
125
+ }
126
+ export function renderFolderOfOne(name, preset) {
127
+ const { frontmatter, body } = buildAgentObject(name, preset);
128
+ return `---\n${stringifyYaml(frontmatter)}---\n\n${body}\n`;
129
+ }
130
+ export function renderCoinrithmPin() {
131
+ return stringifyYaml({
132
+ api: {
133
+ kind: COINRITHM_API.kind,
134
+ baseUrl: COINRITHM_API.baseUrl,
135
+ mcpUrl: COINRITHM_API.mcpUrl,
136
+ openapiVersion: COINRITHM_API.openapiVersion,
137
+ mcpPackage: COINRITHM_API.mcpPackage,
138
+ mcpVersion: COINRITHM_API.mcpVersion,
139
+ },
140
+ venues: ["spot", "futures", "pm"],
141
+ note: "Reference pin. The runner warns if this lags the live API; it does not block self-host.",
142
+ });
143
+ }
144
+ export function renderRuntime(model, trigger) {
145
+ return stringifyYaml({ mode: "self-host", model, trigger });
146
+ }
147
+ // Split a folder-of-one frontmatter+body into the decomposed layout. The
148
+ // resolved AgentSpec must be unchanged (model/trigger via runtime.yaml extends;
149
+ // risk/limits/abstention/killSwitch via $ref; venues/sync/sizing/objective stay
150
+ // inline). Prose moves to character/thesis.md + persona.md.
151
+ export function ejectFiles(fm, body) {
152
+ const files = {};
153
+ files["character/thesis.md"] = (body.trim() || THESIS_BODY) + "\n";
154
+ files["character/persona.md"] = PERSONA_STUB;
155
+ if (fm.risk)
156
+ files["character/risk.yaml"] = stringifyYaml(fm.risk);
157
+ if (fm.limits)
158
+ files["character/limits.yaml"] = stringifyYaml(fm.limits);
159
+ if (fm.abstention)
160
+ files["character/abstention.yaml"] = stringifyYaml(fm.abstention);
161
+ if (fm.killSwitch)
162
+ files["safety/killSwitch.yaml"] = stringifyYaml(fm.killSwitch);
163
+ files["runtime.yaml"] = renderRuntime(fm.model, fm.trigger);
164
+ files["functionality/coinrithm.yaml"] = renderCoinrithmPin();
165
+ const agentFm = {
166
+ spec: fm.spec,
167
+ name: fm.name,
168
+ description: fm.description,
169
+ extends: ["runtime.yaml"],
170
+ venues: fm.venues,
171
+ sync: fm.sync,
172
+ };
173
+ if (fm.sizing)
174
+ agentFm.sizing = fm.sizing;
175
+ if (fm.objective)
176
+ agentFm.objective = fm.objective;
177
+ if (fm.capabilities)
178
+ agentFm.capabilities = fm.capabilities;
179
+ if (fm.risk)
180
+ agentFm.risk = { $ref: "character/risk.yaml" };
181
+ if (fm.limits)
182
+ agentFm.limits = { $ref: "character/limits.yaml" };
183
+ if (fm.abstention)
184
+ agentFm.abstention = { $ref: "character/abstention.yaml" };
185
+ if (fm.killSwitch)
186
+ agentFm.killSwitch = { $ref: "safety/killSwitch.yaml" };
187
+ files["agent.md"] =
188
+ `---\n${stringifyYaml(agentFm)}---\n\n` +
189
+ `Strategy lives in [character/thesis.md](character/thesis.md); ` +
190
+ `persona in [character/persona.md](character/persona.md).\n`;
191
+ return { files };
192
+ }
@@ -0,0 +1,50 @@
1
+ // Core types for the CoinRithm public agent runner.
2
+ //
3
+ // An "agent" is a single SKILL.md: machine-read frontmatter (the AgentSpec
4
+ // types here) plus a plain-language strategy body. The runner reads the spec,
5
+ // wakes on cadence, asks a model for proposed actions, validates them against
6
+ // these caps, and acts via the CoinRithm paper API. Nothing here touches real
7
+ // money.
8
+ export const SPEC_VERSION = "coinrithm.agent.v1";
9
+ export const VENUES = ["spot", "futures", "pm"];
10
+ export const PROVIDERS = [
11
+ "anthropic",
12
+ "openai",
13
+ "groq",
14
+ "openai-compatible",
15
+ ];
16
+ // What the agent declares it is optimizing for — so two similar-looking agents
17
+ // are distinguishable and the scorecard/Arena can read intent.
18
+ export const OBJECTIVE_PRIMARIES = [
19
+ "realized_pnl",
20
+ "risk_adjusted",
21
+ "drawdown_control",
22
+ "calibration",
23
+ ];
24
+ // Opt-in capabilities beyond CoinRithm market reads + paper execution.
25
+ // RESERVED in v1: declared + validated here, wired into the runner in a later
26
+ // slice. `websearch` = external lookups (an injection surface + a cost — it can
27
+ // inform reasoning but NEVER widen a cap, since caps live in the runner);
28
+ // `indicators` = runner-computed RSI/MACD/etc. fed into the observation.
29
+ export const ALLOWED_CAPABILITIES = ["websearch", "indicators"];
30
+ export const ok = () => ({ valid: true });
31
+ export const fail = (code, reason) => ({
32
+ valid: false,
33
+ code,
34
+ reason,
35
+ });
36
+ export function actionVenue(a) {
37
+ if (a.type.startsWith("futures"))
38
+ return "futures";
39
+ if (a.type.startsWith("spot"))
40
+ return "spot";
41
+ return "pm";
42
+ }
43
+ export function isWriteAction(a) {
44
+ // Every proposed action mutates state (set-sltp/cancel included). The model
45
+ // never proposes a read; reads are the runner's job during observe.
46
+ return true;
47
+ }
48
+ export function isOpenAction(a) {
49
+ return a.type === "futures_open" || a.type === "spot_order" || a.type === "pm_open";
50
+ }
@@ -0,0 +1,112 @@
1
+ // Small dependency-free helpers shared across the runner.
2
+ import { createHash } from "node:crypto";
3
+ import { resolve as resolvePath, relative as relativePath, isAbsolute } from "node:path";
4
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ // Parse a cadence string ("30s", "15m", "1h", "4h", "1d") to milliseconds.
6
+ // Returns null for anything unparseable so the skill validator can reject it.
7
+ export function parseCadenceMs(cadence) {
8
+ if (typeof cadence !== "string")
9
+ return null;
10
+ const m = /^\s*(\d+)\s*(s|m|h|d)\s*$/i.exec(cadence);
11
+ if (!m)
12
+ return null;
13
+ const n = Number(m[1]);
14
+ if (!Number.isFinite(n) || n <= 0)
15
+ return null;
16
+ const unit = m[2].toLowerCase();
17
+ const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
18
+ return n * mult;
19
+ }
20
+ // A UTC day key (YYYY-MM-DD) for resetting per-day counters.
21
+ export function dayKey(d = new Date()) {
22
+ return d.toISOString().slice(0, 10);
23
+ }
24
+ // Deep-scan a parsed frontmatter object for anything that looks like a secret.
25
+ // The skill file is meant to be committable and shareable; a real key must
26
+ // NEVER live in it (keys are supplied at runtime via env / encrypted store).
27
+ // Returns a list of human-readable findings (empty = clean).
28
+ const SECRET_KEY_RE = /(^|[_.-])(api[_-]?key|secret|token|password|passwd|bearer|authorization|private[_-]?key)($|[_.-])/i;
29
+ const SECRET_VALUE_RE = /(crk_live_[a-z0-9_]+|sk-[a-z0-9-]{16,}|sk_live_[a-z0-9]{16,}|ghp_[a-z0-9]{20,}|bearer\s+[a-z0-9._-]{12,}|AIza[a-z0-9_-]{20,})/i;
30
+ export function scanForSecrets(value, pathPrefix = "", findings = []) {
31
+ if (value == null)
32
+ return findings;
33
+ if (typeof value === "string") {
34
+ if (SECRET_VALUE_RE.test(value)) {
35
+ findings.push(`value at "${pathPrefix || "(root)"}" looks like a secret/credential`);
36
+ }
37
+ return findings;
38
+ }
39
+ if (Array.isArray(value)) {
40
+ value.forEach((v, i) => scanForSecrets(v, `${pathPrefix}[${i}]`, findings));
41
+ return findings;
42
+ }
43
+ if (typeof value === "object") {
44
+ for (const [k, v] of Object.entries(value)) {
45
+ const here = pathPrefix ? `${pathPrefix}.${k}` : k;
46
+ if (SECRET_KEY_RE.test(k)) {
47
+ findings.push(`field "${here}" must not appear in a skill file (secret-like key)`);
48
+ }
49
+ scanForSecrets(v, here, findings);
50
+ }
51
+ }
52
+ return findings;
53
+ }
54
+ // Short random id (for runId/decisionId/idempotencyKey suffixes).
55
+ export function shortId() {
56
+ // randomUUID is available in Node 18+ (globalThis.crypto).
57
+ return crypto.randomUUID().slice(0, 8);
58
+ }
59
+ // ── Resolver helpers (cross-platform-deterministic) ──────────────────────────
60
+ // Normalize file content for hashing: CRLF/CR -> LF, strip trailing whitespace,
61
+ // so a Windows checkout and a Linux checkout of the same file hash identically.
62
+ export function normalizeContent(text) {
63
+ return text
64
+ .replace(/^/, "") // strip UTF-8 BOM (Windows editors add it)
65
+ .replace(/\r\n/g, "\n")
66
+ .replace(/\r/g, "\n")
67
+ .replace(/\s+$/, "");
68
+ }
69
+ // SHA-256 of normalized content — the manifest lock's per-file contentHash.
70
+ export function sha256(text) {
71
+ return ("sha256:" + createHash("sha256").update(normalizeContent(text), "utf8").digest("hex"));
72
+ }
73
+ // Canonical POSIX path (forward slashes) so the lock is byte-identical on
74
+ // Windows and Linux.
75
+ export function toPosix(p) {
76
+ return p.replace(/\\/g, "/");
77
+ }
78
+ // True iff `child` resolves to a location strictly inside `parent`
79
+ // (path-traversal guard). A `$ref` that escapes the agent folder is rejected.
80
+ export function isPathInside(parent, child) {
81
+ const rel = relativePath(resolvePath(parent), resolvePath(child));
82
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
83
+ }
84
+ // Keep only the most-recent slice of a (possibly large) memory file so a growing
85
+ // journal never blows the model context / the user's token budget.
86
+ export function boundTail(text, maxLines, maxBytes) {
87
+ let lines = text.split("\n");
88
+ if (lines.length > maxLines)
89
+ lines = lines.slice(-maxLines);
90
+ let out = lines.join("\n");
91
+ if (Buffer.byteLength(out, "utf8") > maxBytes) {
92
+ out = Buffer.from(out, "utf8").subarray(-maxBytes).toString("utf8");
93
+ }
94
+ return out;
95
+ }
96
+ // Deterministic JSON: recursively sort object keys so two runs over the same
97
+ // inputs produce a byte-identical manifest.lock.json.
98
+ export function stableStringify(value) {
99
+ return JSON.stringify(sortDeep(value), null, 2);
100
+ }
101
+ export function sortDeep(value) {
102
+ if (Array.isArray(value))
103
+ return value.map(sortDeep);
104
+ if (value && typeof value === "object") {
105
+ const out = {};
106
+ for (const k of Object.keys(value).sort()) {
107
+ out[k] = sortDeep(value[k]);
108
+ }
109
+ return out;
110
+ }
111
+ return value;
112
+ }
@@ -0,0 +1,16 @@
1
+ // Versions stamped into manifest.lock.json so a resolved agent is reproducible
2
+ // only against the exact compile that produced it.
3
+ export const RUNNER_VERSION = "0.1.0";
4
+ export const RESOLVER_VERSION = "1";
5
+ export const MANIFEST_SCHEMA = "coinrithm.manifest.v1";
6
+ // The CoinRithm execution surface a generated agent talks to. Written into
7
+ // functionality/coinrithm.yaml as a version PIN; the CLI warns when an agent's
8
+ // pin lags this, but never blocks self-host use.
9
+ export const COINRITHM_API = {
10
+ kind: "coinrithm-agent-api",
11
+ baseUrl: "https://api.coinrithm.com",
12
+ mcpUrl: "https://mcp.coinrithm.com/mcp",
13
+ openapiVersion: "1.4.0",
14
+ mcpPackage: "@coinrithm/mcp-trading",
15
+ mcpVersion: "0.1.8",
16
+ };
package/dist/tools.js CHANGED
@@ -471,6 +471,20 @@ export function registerTools(server, client) {
471
471
  outputSchema: API_RESULT_OUTPUT_SCHEMA,
472
472
  annotations: readOnlyAnnotations("Export private agent ledger"),
473
473
  }, async ({ venue, eventType, runId, decisionId, status, from, to, agentTrace }, extra) => present(await client.exportLedger({ venue, eventType, runId, decisionId, status, from, to }, requestKey(extra), agentTrace)));
474
+ server.registerTool("export_run_evidence", {
475
+ title: "Export run evidence",
476
+ description: "Export one private reproducibility bundle for a specific agentTrace.runId. " +
477
+ "The bundle includes sanitized ledger rows, execution assumptions, " +
478
+ "retention policy, outcome attribution, and the evidence checklist. " +
479
+ "No public Arena user can see this data. " +
480
+ PAPER_NOTE,
481
+ inputSchema: {
482
+ runId: z.string().min(1).describe("Required run id to export."),
483
+ agentTrace: AGENT_TRACE_SCHEMA,
484
+ },
485
+ outputSchema: API_RESULT_OUTPUT_SCHEMA,
486
+ annotations: readOnlyAnnotations("Export run evidence"),
487
+ }, async ({ runId, agentTrace }, extra) => present(await client.exportLedger({ runId }, requestKey(extra), agentTrace)));
474
488
  server.registerTool("get_arena_leaderboard", {
475
489
  title: "Get Agent Arena leaderboard",
476
490
  description: "The public Agent Arena: opted-in agents ranked by total realized PnL " +