@coinrithm/mcp-trading 0.7.5 → 0.7.6
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/CHANGELOG.md +259 -197
- package/README.md +290 -279
- package/dist/agent/cli.js +22 -1
- package/dist/agent/client.d.ts +1 -0
- package/dist/agent/client.js +7 -0
- package/dist/agent/decision.d.ts +12 -0
- package/dist/agent/decision.js +10 -0
- package/dist/agent/observe.js +100 -6
- package/dist/agent/prompt.js +19 -1
- package/dist/agent/resolve.d.ts +12 -0
- package/dist/agent/resolve.js +63 -3
- package/dist/agent/runner.js +61 -1
- package/dist/agent/state.d.ts +5 -0
- package/dist/agent/state.js +20 -0
- package/dist/agent/strictLint.js +13 -0
- package/dist/agent/templates.js +6 -0
- package/dist/agent/types.d.ts +10 -1
- package/dist/agent/types.js +10 -0
- package/dist/agent/version.d.ts +1 -1
- package/dist/agent/version.js +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.js +10 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +56 -0
- package/package.json +1 -1
package/dist/agent/cli.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { mkdirSync, writeFileSync, existsSync, statSync, readFileSync, openSync, closeSync, unlinkSync, } from "node:fs";
|
|
8
8
|
import { resolve as resolvePath, dirname, join, basename } from "node:path";
|
|
9
9
|
import { parse as parseYaml } from "yaml";
|
|
10
|
-
import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource, } from "./resolve.js";
|
|
10
|
+
import { resolveAgent, ResolveError, mergeProseParts, isSkillProseSource, hostedProseBudget, HOSTED_PROSE_MAX_CHARS, } from "./resolve.js";
|
|
11
11
|
import { buildSpec, loadAgent } from "./skill.js";
|
|
12
12
|
import { validateSkill } from "./skillValidator.js";
|
|
13
13
|
import { strictLint } from "./strictLint.js";
|
|
@@ -101,6 +101,27 @@ export function cmdValidate(path, mode = "self-host") {
|
|
|
101
101
|
const spec = buildSpec(raw);
|
|
102
102
|
const lint = [...strictLint(raw), ...checkCapabilityDrift(resolved, spec)];
|
|
103
103
|
const v = validateSkill({ spec, body: resolved.mergedProse, raw }, mode);
|
|
104
|
+
// Hosted-only: the managed deploy/edit API caps the merged strategy prose at
|
|
105
|
+
// HOSTED_PROSE_MAX_CHARS and REVERTS the save when it is exceeded, so a
|
|
106
|
+
// bundle that resolves and lints perfectly can still be undeployable through
|
|
107
|
+
// the Studio. Measured 2026-08-19 after a user hit the wall: 4 of 9 example
|
|
108
|
+
// bundles were over (contrarian-carl 8,159, mia 8,175, olivia 8,587,
|
|
109
|
+
// pia-pump-fader 11,787) while the corpus README claimed they all pass
|
|
110
|
+
// `validate --hosted`. Checking it here is what makes that claim true and
|
|
111
|
+
// stops the corpus drifting back over the wall.
|
|
112
|
+
if (mode === "hosted") {
|
|
113
|
+
const budget = hostedProseBudget(resolved.mergedProse);
|
|
114
|
+
if (!budget.fits) {
|
|
115
|
+
lint.push({
|
|
116
|
+
code: "hosted_prose_too_long",
|
|
117
|
+
path: "character/*.md",
|
|
118
|
+
message: `merged strategy prose is ${budget.used} chars, ${budget.over} over the hosted ` +
|
|
119
|
+
`limit of ${HOSTED_PROSE_MAX_CHARS} — the managed deploy would reject this and ` +
|
|
120
|
+
`revert to the template. Self-host has no such cap. Note the count includes a ` +
|
|
121
|
+
`"<!-- path -->" header per prose file, not just the bodies.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
104
125
|
const lintFatal = mode === "hosted";
|
|
105
126
|
const lines = [];
|
|
106
127
|
for (const i of lint) {
|
package/dist/agent/client.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export declare class CoinRithmClient {
|
|
|
37
37
|
coinId?: string;
|
|
38
38
|
}, trace?: AgentTrace): Promise<ApiResult>;
|
|
39
39
|
resolve(q: string, trace?: AgentTrace): Promise<ApiResult>;
|
|
40
|
+
cryptoMovers(direction: "gainers" | "losers", limit: number, trace?: AgentTrace): Promise<ApiResult>;
|
|
40
41
|
market(coinId: string, trace?: AgentTrace): Promise<ApiResult>;
|
|
41
42
|
candles(coinId: string, range: string, trace?: AgentTrace): Promise<ApiResult>;
|
|
42
43
|
trades(query?: {
|
package/dist/agent/client.js
CHANGED
|
@@ -116,6 +116,13 @@ export class CoinRithmClient {
|
|
|
116
116
|
resolve(q, trace) {
|
|
117
117
|
return this.request("GET", "/api/agent/resolve", { query: { q }, trace });
|
|
118
118
|
}
|
|
119
|
+
// Keyless public universe scan (top 24h movers). The Bearer header rides
|
|
120
|
+
// along harmlessly — the /api/coins routes are public and ignore it.
|
|
121
|
+
cryptoMovers(direction, limit, trace) {
|
|
122
|
+
return this.request("GET", direction === "losers"
|
|
123
|
+
? "/api/coins/top-losers"
|
|
124
|
+
: "/api/coins/top-gainers", { query: { limit }, trace });
|
|
125
|
+
}
|
|
119
126
|
market(coinId, trace) {
|
|
120
127
|
return this.request("GET", `/api/agent/market/${encodeURIComponent(coinId)}`, { trace });
|
|
121
128
|
}
|
package/dist/agent/decision.d.ts
CHANGED
|
@@ -53,16 +53,22 @@ export declare const actionSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<
|
|
|
53
53
|
positionId: z.ZodEffects<z.ZodTypeAny, any, unknown>;
|
|
54
54
|
stopLossPrice: z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>;
|
|
55
55
|
takeProfitPrice: z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>;
|
|
56
|
+
confidence: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>, any, unknown>;
|
|
57
|
+
rationaleSummary: z.ZodOptional<z.ZodString>;
|
|
56
58
|
}, "strict", z.ZodTypeAny, {
|
|
57
59
|
type: "futures_set_sltp";
|
|
58
60
|
stopLossPrice?: any;
|
|
59
61
|
takeProfitPrice?: any;
|
|
60
62
|
positionId?: any;
|
|
63
|
+
confidence?: any;
|
|
64
|
+
rationaleSummary?: string | undefined;
|
|
61
65
|
}, {
|
|
62
66
|
type: "futures_set_sltp";
|
|
63
67
|
stopLossPrice?: unknown;
|
|
64
68
|
takeProfitPrice?: unknown;
|
|
65
69
|
positionId?: unknown;
|
|
70
|
+
confidence?: unknown;
|
|
71
|
+
rationaleSummary?: string | undefined;
|
|
66
72
|
}>, z.ZodObject<{
|
|
67
73
|
type: z.ZodLiteral<"spot_order">;
|
|
68
74
|
symbol: z.ZodString;
|
|
@@ -96,11 +102,17 @@ export declare const actionSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<
|
|
|
96
102
|
}>, z.ZodObject<{
|
|
97
103
|
type: z.ZodLiteral<"spot_cancel">;
|
|
98
104
|
orderId: z.ZodEffects<z.ZodTypeAny, any, unknown>;
|
|
105
|
+
confidence: z.ZodEffects<z.ZodOptional<z.ZodNullable<z.ZodEffects<z.ZodTypeAny, any, unknown>>>, any, unknown>;
|
|
106
|
+
rationaleSummary: z.ZodOptional<z.ZodString>;
|
|
99
107
|
}, "strict", z.ZodTypeAny, {
|
|
100
108
|
type: "spot_cancel";
|
|
109
|
+
confidence?: any;
|
|
110
|
+
rationaleSummary?: string | undefined;
|
|
101
111
|
orderId?: any;
|
|
102
112
|
}, {
|
|
103
113
|
type: "spot_cancel";
|
|
114
|
+
confidence?: unknown;
|
|
115
|
+
rationaleSummary?: string | undefined;
|
|
104
116
|
orderId?: unknown;
|
|
105
117
|
}>, z.ZodObject<{
|
|
106
118
|
type: z.ZodLiteral<"pm_open">;
|
package/dist/agent/decision.js
CHANGED
|
@@ -59,6 +59,13 @@ const futuresSetSltp = z
|
|
|
59
59
|
positionId: num(z.number()),
|
|
60
60
|
stopLossPrice: num(z.number()).nullable().optional(),
|
|
61
61
|
takeProfitPrice: num(z.number()).nullable().optional(),
|
|
62
|
+
// Accepted-and-unused: the output contract asks for per-action confidence,
|
|
63
|
+
// so models copy it onto EVERY action. The two schemas that lacked it were
|
|
64
|
+
// fail-closing whole decisions under .strict() (~180-200 discarded
|
|
65
|
+
// fleet-wide per day, live-measured 2026-08-19). Same tolerance the four
|
|
66
|
+
// trade actions already have.
|
|
67
|
+
confidence,
|
|
68
|
+
rationaleSummary: z.string().optional(),
|
|
62
69
|
})
|
|
63
70
|
.strict();
|
|
64
71
|
const spotOrder = z
|
|
@@ -78,6 +85,9 @@ const spotCancel = z
|
|
|
78
85
|
.object({
|
|
79
86
|
type: z.literal("spot_cancel"),
|
|
80
87
|
orderId: num(z.number()),
|
|
88
|
+
// Same accepted-and-unused tolerance as futures_set_sltp above.
|
|
89
|
+
confidence,
|
|
90
|
+
rationaleSummary: z.string().optional(),
|
|
81
91
|
})
|
|
82
92
|
.strict();
|
|
83
93
|
// pm_open accepts EITHER a short ref (pm1…pmN, what the prompt now asks for) OR
|
package/dist/agent/observe.js
CHANGED
|
@@ -8,6 +8,11 @@ import { scanSetups } from "./setups.js";
|
|
|
8
8
|
// (~5-min fresh, ~288 bars — ample for EMA50/RSI14/Bollinger20), which suits the
|
|
9
9
|
// short cadence the hosted house agents run on. Probe-verified 2026-06-17.
|
|
10
10
|
const INDICATOR_RANGE = "1D";
|
|
11
|
+
// `universe_scan` bounds: how many top movers to pull, and how many of those
|
|
12
|
+
// to fully resolve into tradable watch entries (each resolved row costs a
|
|
13
|
+
// resolve + market [+ candles] call).
|
|
14
|
+
const UNIVERSE_SCAN_LIMIT = 15;
|
|
15
|
+
const UNIVERSE_RESOLVE_TOP = 3;
|
|
11
16
|
// Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
|
|
12
17
|
// discovers PM markets about the coins it actually has a price view on.
|
|
13
18
|
const PM_COIN_NAMES = {
|
|
@@ -42,7 +47,16 @@ export function isCalibrationChurnMarket(market) {
|
|
|
42
47
|
// Tolerant by design: any failure (HTTP error, malformed/sparse candles) returns
|
|
43
48
|
// null so the cycle proceeds with price-only context rather than skipping.
|
|
44
49
|
async function fetchIndicators(client, coinId, trace) {
|
|
45
|
-
|
|
50
|
+
// The try honors the documented tolerance for SYNCHRONOUS throws too (an
|
|
51
|
+
// unexpected client error must degrade to price-only context, never kill
|
|
52
|
+
// the cycle).
|
|
53
|
+
let cr;
|
|
54
|
+
try {
|
|
55
|
+
cr = await client.candles(coinId, INDICATOR_RANGE, trace);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
46
60
|
if (!cr.ok)
|
|
47
61
|
return null;
|
|
48
62
|
// Endpoint shape: { candles: [{ t, o, h, l, c, v }] } ascending (oldest first).
|
|
@@ -272,6 +286,78 @@ export async function observe(client, spec, state, trace) {
|
|
|
272
286
|
}
|
|
273
287
|
watch.push(entry);
|
|
274
288
|
}
|
|
289
|
+
// `universe_scan` capability (2026-08-18, direct user request): discover the
|
|
290
|
+
// top 24h movers across the whole tracked universe, resolve the strongest
|
|
291
|
+
// few into FULL watch entries (marked discovered) and pass the remainder as
|
|
292
|
+
// compact context. Bounds: one movers call + up to
|
|
293
|
+
// UNIVERSE_RESOLVE_TOP resolve/market(+candles) calls per cycle — the same
|
|
294
|
+
// per-symbol cost as ~3 extra watchlist rows, all against CoinRithm's own
|
|
295
|
+
// API (never the model quota). Failures degrade to "no universe section",
|
|
296
|
+
// never a skipped cycle. Watchlist + blocklist symbols are excluded up
|
|
297
|
+
// front so a discovered row can never duplicate or bypass the deny-list.
|
|
298
|
+
let universeMovers;
|
|
299
|
+
if (spec.capabilities.includes("universe_scan")) {
|
|
300
|
+
const mv = await client.cryptoMovers("gainers", UNIVERSE_SCAN_LIMIT, trace);
|
|
301
|
+
if (mv.ok && Array.isArray(mv.data)) {
|
|
302
|
+
const excluded = new Set([...spec.risk.watchlist, ...(spec.risk.blocklist ?? [])].map((s) => s.toUpperCase()));
|
|
303
|
+
const rows = mv.data
|
|
304
|
+
.map(asObj)
|
|
305
|
+
.map((r) => ({
|
|
306
|
+
symbol: (asStr(r.symbol) ?? "").toUpperCase(),
|
|
307
|
+
name: asStr(r.name),
|
|
308
|
+
change24hPct: asNum(r.change24h),
|
|
309
|
+
priceUsd: asNum(r.currentPrice),
|
|
310
|
+
// The movers row already carries the ucid, which IS the coinId every
|
|
311
|
+
// downstream call takes. Kept so the resolve round-trip below can be
|
|
312
|
+
// skipped — see the comment there.
|
|
313
|
+
coinId: asStr(r.ucid),
|
|
314
|
+
}))
|
|
315
|
+
.filter((r) => r.symbol && !excluded.has(r.symbol));
|
|
316
|
+
const resolveTop = rows.slice(0, UNIVERSE_RESOLVE_TOP);
|
|
317
|
+
for (const row of resolveTop) {
|
|
318
|
+
// Prefer the ucid the movers feed already gave us. Resolving the
|
|
319
|
+
// SYMBOL instead was both a wasted call per discovered mover and a
|
|
320
|
+
// correctness hazard: symbols collide across listings, so the resolver
|
|
321
|
+
// could hand back a different coin than the one that actually moved,
|
|
322
|
+
// and the agent would analyze (and trade) that other coin.
|
|
323
|
+
let coinId = row.coinId;
|
|
324
|
+
let resolvedName;
|
|
325
|
+
if (!coinId) {
|
|
326
|
+
const rs = await client.resolve(row.symbol, trace);
|
|
327
|
+
const match = asObj(asObj(rs.data).match);
|
|
328
|
+
coinId =
|
|
329
|
+
rs.ok && match.coinId != null ? String(match.coinId) : undefined;
|
|
330
|
+
resolvedName = asStr(match.name);
|
|
331
|
+
}
|
|
332
|
+
if (!coinId)
|
|
333
|
+
continue;
|
|
334
|
+
const mk = await client.market(coinId, trace);
|
|
335
|
+
const m = asObj(mk.data);
|
|
336
|
+
const price = asObj(m.price);
|
|
337
|
+
const entry = {
|
|
338
|
+
symbol: row.symbol,
|
|
339
|
+
coinId,
|
|
340
|
+
name: resolvedName ?? row.name ?? undefined,
|
|
341
|
+
priceUsd: asNum(price.usd) ?? row.priceUsd,
|
|
342
|
+
change1h: asNum(price.change1h),
|
|
343
|
+
change24h: asNum(price.change24h) ?? row.change24hPct,
|
|
344
|
+
change7d: asNum(price.change7d),
|
|
345
|
+
sentimentBullishPct: asNum(asObj(m.sentiment).bullishPct) ?? undefined,
|
|
346
|
+
freshness: freshnessOf(asObj(m.observation)),
|
|
347
|
+
discovered: true,
|
|
348
|
+
};
|
|
349
|
+
if (wantIndicators) {
|
|
350
|
+
const ind = await fetchIndicators(client, coinId, trace);
|
|
351
|
+
if (ind)
|
|
352
|
+
entry.indicators = ind;
|
|
353
|
+
}
|
|
354
|
+
watch.push(entry);
|
|
355
|
+
}
|
|
356
|
+
const context = rows.slice(UNIVERSE_RESOLVE_TOP);
|
|
357
|
+
if (context.length > 0)
|
|
358
|
+
universeMovers = context;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
275
361
|
// Spot resting orders (for cancel + affordability) — only if spot is enabled.
|
|
276
362
|
const wantSpot = spec.venues.includes("spot");
|
|
277
363
|
const wantPm = spec.venues.includes("pm");
|
|
@@ -451,12 +537,19 @@ export async function observe(client, spec, state, trace) {
|
|
|
451
537
|
}
|
|
452
538
|
}
|
|
453
539
|
// News context (only with the `news` capability): recent high-importance news
|
|
454
|
-
// for the
|
|
455
|
-
//
|
|
456
|
-
//
|
|
540
|
+
// for the coins the agent is actually LOOKING AT this cycle — the watch array,
|
|
541
|
+
// which includes any `universe_scan`-discovered movers. Keying this to the
|
|
542
|
+
// static watchlist alone (the old behavior) starved exactly the case news
|
|
543
|
+
// exists for: a discovered pump whose catalyst the agent is supposed to
|
|
544
|
+
// investigate before acting (the pump-fade pattern, 2026-08-19). One cached
|
|
545
|
+
// call; degrades to no news on failure (never blocks a cycle).
|
|
457
546
|
let news;
|
|
458
|
-
|
|
459
|
-
|
|
547
|
+
const newsCoins = Array.from(new Set([
|
|
548
|
+
...spec.risk.watchlist,
|
|
549
|
+
...watch.map((w) => w.symbol.toUpperCase()),
|
|
550
|
+
]));
|
|
551
|
+
if (wantNews && newsCoins.length > 0) {
|
|
552
|
+
const nr = await client.agentNews({ coins: newsCoins.join(","), limit: 8, hours: 48 }, trace);
|
|
460
553
|
if (nr.ok) {
|
|
461
554
|
news = asArr(asObj(nr.data).items)
|
|
462
555
|
.map(asObj)
|
|
@@ -495,6 +588,7 @@ export async function observe(client, spec, state, trace) {
|
|
|
495
588
|
syncCursor,
|
|
496
589
|
newClosedTrades,
|
|
497
590
|
polledBeforeWrite,
|
|
591
|
+
universeMovers,
|
|
498
592
|
};
|
|
499
593
|
// Skip only when there is NOTHING actionable: no coin resolved (futures/spot)
|
|
500
594
|
// AND no PM candidate (pm). A pm-only agent proceeds on its discovered markets.
|
package/dist/agent/prompt.js
CHANGED
|
@@ -63,7 +63,15 @@ opts = {}) {
|
|
|
63
63
|
`- venues you may act in: ${v.join(", ")}`,
|
|
64
64
|
`- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (futures margin / spot buy notional / PM stake)`,
|
|
65
65
|
`- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
|
|
66
|
-
|
|
66
|
+
// With universe_scan, the validator's gate is WATCH-membership (manual
|
|
67
|
+
// watchlist ∪ this cycle's discovered entries) — saying "ONLY these" here
|
|
68
|
+
// while the universe-scan section below calls discovered movers tradable
|
|
69
|
+
// made cap-obedient models refuse every discovered candidate (the caps
|
|
70
|
+
// header says proposing outside a cap wastes the cycle). Keep the two
|
|
71
|
+
// sections telling one story.
|
|
72
|
+
spec.capabilities.includes("universe_scan")
|
|
73
|
+
? `- tradable symbols (spot + futures): your watchlist (${r.watchlist.join(", ")}) PLUS this cycle's watch entries marked \`discovered: true\` — nothing outside those`
|
|
74
|
+
: `- watchlist (spot + futures use ONLY these): ${r.watchlist.join(", ")}`,
|
|
67
75
|
...(r.blocklist && r.blocklist.length > 0
|
|
68
76
|
? [
|
|
69
77
|
`- deny-list (NEVER open these, even if on the watchlist): ${r.blocklist.join(", ")}`,
|
|
@@ -87,6 +95,15 @@ opts = {}) {
|
|
|
87
95
|
"- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
|
|
88
96
|
]
|
|
89
97
|
: []),
|
|
98
|
+
...(spec.capabilities.includes("universe_scan")
|
|
99
|
+
? [
|
|
100
|
+
"",
|
|
101
|
+
"## Universe scan (discovered movers) — candidates beyond your watchlist",
|
|
102
|
+
"Watch entries with `discovered: true` are today's strongest 24h movers across the WHOLE tracked universe, resolved with the same price/sentiment (and indicators) data as your watchlist. observation.universeMovers lists further movers as symbol + 24h change only (context — you cannot trade those directly this cycle).",
|
|
103
|
+
"- Treat a discovered candidate like any other symbol: analyze it for catalysts, exhaustion and reversal BEFORE acting. A big 24h pump is as often a top as a beginning — chasing green candles blind is how discovery loses money.",
|
|
104
|
+
"- All your normal risk rules apply unchanged: caps, stops, blocklist, confidence floor. Discovery widens what you can SEE, never what you may risk.",
|
|
105
|
+
]
|
|
106
|
+
: []),
|
|
90
107
|
...(spec.capabilities.includes("news")
|
|
91
108
|
? [
|
|
92
109
|
"",
|
|
@@ -169,6 +186,7 @@ export function buildUserPrompt(obs, journal) {
|
|
|
169
186
|
watch: obs.watch,
|
|
170
187
|
setups: obs.setups,
|
|
171
188
|
news: obs.news,
|
|
189
|
+
universeMovers: obs.universeMovers,
|
|
172
190
|
marketMood: obs.marketMood,
|
|
173
191
|
newClosedTrades: obs.newClosedTrades.slice(0, 20),
|
|
174
192
|
polledBeforeWrite: obs.polledBeforeWrite,
|
package/dist/agent/resolve.d.ts
CHANGED
|
@@ -3,9 +3,21 @@ export declare class ResolveError extends Error {
|
|
|
3
3
|
issues: ResolveIssue[];
|
|
4
4
|
constructor(issues: ResolveIssue[]);
|
|
5
5
|
}
|
|
6
|
+
export declare const proseBody: (raw: string) => string;
|
|
7
|
+
export declare const GUARDS_FILE = "character/guards.md";
|
|
8
|
+
export declare const GUARDS_HEADER = "## HARD BEHAVIORAL GUARDS \u2014 never violate these";
|
|
9
|
+
export declare const GUARDS_FOOTER = "(These guards override every other instruction in this strategy. When a guard conflicts with an opportunity, the guard wins and the correct output is a skip that names the guard.)";
|
|
10
|
+
export declare const wrapGuardsProse: (body: string) => string;
|
|
6
11
|
export declare function isSkillProseSource(source: string): boolean;
|
|
7
12
|
export declare function mergeProseParts(parts: Array<{
|
|
8
13
|
source: string;
|
|
9
14
|
text: string;
|
|
10
15
|
}>): string;
|
|
11
16
|
export declare function resolveAgent(inputPath: string): ResolvedAgent;
|
|
17
|
+
export declare const HOSTED_PROSE_MAX_CHARS = 8000;
|
|
18
|
+
/** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
|
|
19
|
+
export declare const hostedProseBudget: (mergedProse: string) => {
|
|
20
|
+
used: number;
|
|
21
|
+
over: number;
|
|
22
|
+
fits: boolean;
|
|
23
|
+
};
|
package/dist/agent/resolve.js
CHANGED
|
@@ -47,6 +47,32 @@ const JOURNAL_MAX_LINES = 200;
|
|
|
47
47
|
const JOURNAL_MAX_BYTES = 8_000;
|
|
48
48
|
// Optional prose files (markdown the LLM reads), in assembly order.
|
|
49
49
|
const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
|
|
50
|
+
// Prose files carry an OPTIONAL YAML frontmatter block (type/title/description/
|
|
51
|
+
// tags) that is authoring metadata, not doctrine — the model gains nothing from
|
|
52
|
+
// `tags: [agent, persona, mean-reversion]`. It was being merged verbatim into
|
|
53
|
+
// the system prompt: pure noise, and on the hosted path it also consumed the
|
|
54
|
+
// 8,000-char strategy budget (measured 2026-08-19: ~1.0k chars across a
|
|
55
|
+
// decomposed bundle's thesis/persona/journal). Skill files already strip theirs
|
|
56
|
+
// (their frontmatter is parsed for the cap patch), and guards.md strips too —
|
|
57
|
+
// this brings the remaining prose files in line. Files WITHOUT frontmatter are
|
|
58
|
+
// returned unchanged.
|
|
59
|
+
export const proseBody = (raw) => {
|
|
60
|
+
const src = raw.replace(/\r\n/g, "\n");
|
|
61
|
+
const m = /^---\n[\s\S]*?\n---\n?([\s\S]*)$/.exec(src);
|
|
62
|
+
return (m ? m[1] : src).trim();
|
|
63
|
+
};
|
|
64
|
+
// First-class hard-guards file (2026-08-19, audit rank 7). User-authored
|
|
65
|
+
// behavioral borders that machine caps cannot express ("never open a short
|
|
66
|
+
// unless a qualifying pump preceded it") previously lived as an undocumented
|
|
67
|
+
// "Hard borders" paragraph buried mid-persona — present in only 5 of 8
|
|
68
|
+
// bundles and easy for a forker to miss. character/guards.md gets a dedicated
|
|
69
|
+
// slot: loaded LAST so the block lands at the END of the strategy prose,
|
|
70
|
+
// immediately adjacent to the system prompt's hard-caps section, wrapped in a
|
|
71
|
+
// high-salience header plus an explicit guards-win-conflicts rule.
|
|
72
|
+
export const GUARDS_FILE = "character/guards.md";
|
|
73
|
+
export const GUARDS_HEADER = "## HARD BEHAVIORAL GUARDS — never violate these";
|
|
74
|
+
export const GUARDS_FOOTER = "(These guards override every other instruction in this strategy. When a guard conflicts with an opportunity, the guard wins and the correct output is a skip that names the guard.)";
|
|
75
|
+
export const wrapGuardsProse = (body) => `${GUARDS_HEADER}\n\n${body.trim()}\n\n${GUARDS_FOOTER}`;
|
|
50
76
|
const FUNCTIONALITY_PIN = "functionality/coinrithm.yaml";
|
|
51
77
|
// Enforced cap field names. sizing.yaml is SOFT guidance and must NOT contain
|
|
52
78
|
// any of these (or a user could think a limit binds when it does not).
|
|
@@ -427,8 +453,9 @@ function resolveDirectory(dir) {
|
|
|
427
453
|
const p = join(dir, pf);
|
|
428
454
|
if (existsSync(p)) {
|
|
429
455
|
const abs = safePath(ctx, pf, "prose");
|
|
430
|
-
if (abs)
|
|
431
|
-
proseParts.push({ source: pf, text: readHashed(ctx, abs) });
|
|
456
|
+
if (abs) {
|
|
457
|
+
proseParts.push({ source: pf, text: proseBody(readHashed(ctx, abs)) });
|
|
458
|
+
}
|
|
432
459
|
}
|
|
433
460
|
}
|
|
434
461
|
proseParts.push(...skillProse);
|
|
@@ -437,13 +464,28 @@ function resolveDirectory(dir) {
|
|
|
437
464
|
if (existsSync(journalPath)) {
|
|
438
465
|
const abs = safePath(ctx, "journal/notes.md", "journal");
|
|
439
466
|
if (abs) {
|
|
440
|
-
const full = readHashed(ctx, abs);
|
|
467
|
+
const full = proseBody(readHashed(ctx, abs));
|
|
441
468
|
proseParts.push({
|
|
442
469
|
source: "journal/notes.md",
|
|
443
470
|
text: boundTail(full, JOURNAL_MAX_LINES, JOURNAL_MAX_BYTES),
|
|
444
471
|
});
|
|
445
472
|
}
|
|
446
473
|
}
|
|
474
|
+
// Hard behavioral guards — see GUARDS_FILE above. Pushed AFTER the journal
|
|
475
|
+
// so the wrapped block is the final prose the model reads before the caps
|
|
476
|
+
// section. Frontmatter is optional and stripped (only the body is doctrine);
|
|
477
|
+
// an empty body contributes nothing.
|
|
478
|
+
const guardsAbsPath = join(dir, GUARDS_FILE);
|
|
479
|
+
if (existsSync(guardsAbsPath)) {
|
|
480
|
+
const abs = safePath(ctx, GUARDS_FILE, "guards");
|
|
481
|
+
if (abs) {
|
|
482
|
+
const raw = readHashed(ctx, abs);
|
|
483
|
+
const body = (raw.startsWith("---") ? parseFrontmatter(raw).body : raw).trim();
|
|
484
|
+
if (body) {
|
|
485
|
+
proseParts.push({ source: GUARDS_FILE, text: wrapGuardsProse(body) });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
447
489
|
// Optional API/tool contract pin. It is locked for reproducibility and stale
|
|
448
490
|
// warnings, but it is not part of AgentSpec and is never sent to the model.
|
|
449
491
|
const functionalityPath = join(dir, FUNCTIONALITY_PIN);
|
|
@@ -542,3 +584,21 @@ export function resolveAgent(inputPath) {
|
|
|
542
584
|
{ code: "input_invalid", message: "input must be a file or a directory" },
|
|
543
585
|
]);
|
|
544
586
|
}
|
|
587
|
+
// ── Hosted strategy-prose budget ───────────────────────────────────────────
|
|
588
|
+
// The managed (Studio) deploy/edit API caps the merged strategy prose and
|
|
589
|
+
// reverts the save past the cap; the self-host runner has NO such limit. The
|
|
590
|
+
// cap is not arbitrary — the merged prose becomes the system prompt, and an
|
|
591
|
+
// oversized prompt is what previously pushed small free-tier models to ~69k
|
|
592
|
+
// tokens / 413s per cycle. Mirrored here (backend-v2
|
|
593
|
+
// controllers/agentManage.ts sanitizeStrategyProse) so `validate --hosted`
|
|
594
|
+
// can catch it before a user does.
|
|
595
|
+
export const HOSTED_PROSE_MAX_CHARS = 8000;
|
|
596
|
+
/** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
|
|
597
|
+
export const hostedProseBudget = (mergedProse) => {
|
|
598
|
+
const used = mergedProse.replace(/\r\n/g, "\n").trim().length;
|
|
599
|
+
return {
|
|
600
|
+
used,
|
|
601
|
+
over: Math.max(0, used - HOSTED_PROSE_MAX_CHARS),
|
|
602
|
+
fits: used <= HOSTED_PROSE_MAX_CHARS,
|
|
603
|
+
};
|
|
604
|
+
};
|
package/dist/agent/runner.js
CHANGED
|
@@ -14,7 +14,7 @@ import { validateAction } from "./decisionValidator.js";
|
|
|
14
14
|
import { resolvePmRef } from "./resolvePm.js";
|
|
15
15
|
import { fetchQuote, executeAction } from "./act.js";
|
|
16
16
|
import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
|
|
17
|
-
import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
|
|
17
|
+
import { rollDay, checkKillSwitch, accrueRealized, saveState, isPermanentModelError, isAuthFailureSkip, PERMANENT_MODEL_ERROR_THRESHOLD, AUTH_FAILURE_THRESHOLD, } from "./state.js";
|
|
18
18
|
import { asObj, asNum, asStr } from "./extract.js";
|
|
19
19
|
import { parseCadenceMs, sleep } from "./util.js";
|
|
20
20
|
import { buildObservationReceipt } from "./observationReceipt.js";
|
|
@@ -429,6 +429,29 @@ export async function runCycle(deps) {
|
|
|
429
429
|
}
|
|
430
430
|
if (obs.skip) {
|
|
431
431
|
state.consecutiveRejectCycles += 1;
|
|
432
|
+
// Permanent-failure classification: a revoked/invalid CoinRithm key
|
|
433
|
+
// answers 401 deterministically — after the threshold, disable with the
|
|
434
|
+
// machine-readable 'key_invalid' prefix the scheduler's self-heal exempts
|
|
435
|
+
// (the old path revived such agents into ~1,500 guaranteed-dead
|
|
436
|
+
// cycles/day). A transient rotation blip stays under the threshold.
|
|
437
|
+
if (isAuthFailureSkip(obs.skip)) {
|
|
438
|
+
state.consecutiveAuthFailures = (state.consecutiveAuthFailures ?? 0) + 1;
|
|
439
|
+
if (state.consecutiveAuthFailures >= AUTH_FAILURE_THRESHOLD) {
|
|
440
|
+
state.disabled = true;
|
|
441
|
+
state.disabledReason = `key_invalid: CoinRithm key rejected (HTTP 401) on ${state.consecutiveAuthFailures} consecutive cycles`;
|
|
442
|
+
saveState(stateFile, state);
|
|
443
|
+
log(`disabled: ${state.disabledReason}`);
|
|
444
|
+
return {
|
|
445
|
+
decision: "skip",
|
|
446
|
+
skipReason: obs.skip,
|
|
447
|
+
planned: [],
|
|
448
|
+
disabled: true,
|
|
449
|
+
disabledReason: state.disabledReason,
|
|
450
|
+
live,
|
|
451
|
+
...observationReceipt,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
432
455
|
saveState(stateFile, state);
|
|
433
456
|
log(`skip: ${obs.skip}`);
|
|
434
457
|
return {
|
|
@@ -439,6 +462,8 @@ export async function runCycle(deps) {
|
|
|
439
462
|
...observationReceipt,
|
|
440
463
|
};
|
|
441
464
|
}
|
|
465
|
+
// A full observation implies /me succeeded — the auth-failure streak is over.
|
|
466
|
+
state.consecutiveAuthFailures = 0;
|
|
442
467
|
// GATE (slice 2): only SPEND an LLM call when a deterministic trigger fires — a
|
|
443
468
|
// flagged entry setup or an open position to manage. No trigger => a cheap
|
|
444
469
|
// heartbeat (zero tokens). A heartbeat is neither a model reject nor a failure,
|
|
@@ -520,6 +545,40 @@ export async function runCycle(deps) {
|
|
|
520
545
|
};
|
|
521
546
|
if (!res.ok) {
|
|
522
547
|
state.consecutiveModelFailures += 1;
|
|
548
|
+
// Permanent-failure classification: a 404/model_not_found is a
|
|
549
|
+
// DECOMMISSIONED or misconfigured model that will fail every cycle
|
|
550
|
+
// forever (live-measured: 93% of one agent's cycles for days, revived
|
|
551
|
+
// 7x in 3h). Three consecutive occurrences rules out a routing fluke;
|
|
552
|
+
// then disable with the 'model_unavailable' prefix the scheduler's
|
|
553
|
+
// self-heal exempts. Transient errors reset the permanent streak.
|
|
554
|
+
if (isPermanentModelError(res.error)) {
|
|
555
|
+
state.consecutivePermanentModelErrors =
|
|
556
|
+
(state.consecutivePermanentModelErrors ?? 0) + 1;
|
|
557
|
+
if (state.consecutivePermanentModelErrors >=
|
|
558
|
+
PERMANENT_MODEL_ERROR_THRESHOLD) {
|
|
559
|
+
state.disabled = true;
|
|
560
|
+
state.disabledReason = `model_unavailable: ${res.error.slice(0, 160)}`;
|
|
561
|
+
saveState(stateFile, state);
|
|
562
|
+
log(`disabled: ${state.disabledReason}`);
|
|
563
|
+
return {
|
|
564
|
+
decision: "skip",
|
|
565
|
+
skipReason: `model error: ${res.error}`,
|
|
566
|
+
planned: [],
|
|
567
|
+
modelFailed: true,
|
|
568
|
+
disabled: true,
|
|
569
|
+
disabledReason: state.disabledReason,
|
|
570
|
+
live,
|
|
571
|
+
...meter,
|
|
572
|
+
decisionType: "model_error",
|
|
573
|
+
writeAttempted: 0,
|
|
574
|
+
writeAccepted: 0,
|
|
575
|
+
...observationReceipt,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else {
|
|
580
|
+
state.consecutivePermanentModelErrors = 0;
|
|
581
|
+
}
|
|
523
582
|
saveState(stateFile, state);
|
|
524
583
|
log(`model error: ${res.error}`);
|
|
525
584
|
return {
|
|
@@ -557,6 +616,7 @@ export async function runCycle(deps) {
|
|
|
557
616
|
};
|
|
558
617
|
}
|
|
559
618
|
state.consecutiveModelFailures = 0;
|
|
619
|
+
state.consecutivePermanentModelErrors = 0;
|
|
560
620
|
decision = parsed.decision;
|
|
561
621
|
}
|
|
562
622
|
// Reasoning captured for the Arena terminal (keystone transparency): the
|
package/dist/agent/state.d.ts
CHANGED
|
@@ -4,4 +4,9 @@ export declare function loadState(file: string | undefined, runId: string): RunS
|
|
|
4
4
|
export declare function saveState(file: string | undefined, state: RunState): void;
|
|
5
5
|
export declare function rollDay(state: RunState): RunState;
|
|
6
6
|
export declare function accrueRealized(state: RunState, closedTrades: Record<string, unknown>[]): void;
|
|
7
|
+
export declare const PERMANENT_MODEL_ERROR_RE: RegExp;
|
|
8
|
+
export declare const PERMANENT_MODEL_ERROR_THRESHOLD = 3;
|
|
9
|
+
export declare const AUTH_FAILURE_THRESHOLD = 10;
|
|
10
|
+
export declare const isPermanentModelError: (error: string) => boolean;
|
|
11
|
+
export declare const isAuthFailureSkip: (skipReason: string) => boolean;
|
|
7
12
|
export declare function checkKillSwitch(spec: AgentSpec, state: RunState): string | null;
|
package/dist/agent/state.js
CHANGED
|
@@ -84,6 +84,26 @@ export function accrueRealized(state, closedTrades) {
|
|
|
84
84
|
// floored at this many consecutive failures regardless of an agent's own (lower)
|
|
85
85
|
// setting. The scheduler additionally auto-revives any model-failure disable.
|
|
86
86
|
const MODEL_FAILURE_FLOOR = 10;
|
|
87
|
+
// ── Permanent-failure classification (2026-08-19) ───────────────────────────
|
|
88
|
+
// The generic kill-switch treats every failure as transient — correct for
|
|
89
|
+
// timeouts/blips, catastrophic for DETERMINISTIC failures. Live-measured: one
|
|
90
|
+
// agent spent 93% of 782 cycles/24h on a Groq 404 (model decommissioned),
|
|
91
|
+
// revived 7 times in 3h by the self-heal; four others burned ~1,500 cycles/day
|
|
92
|
+
// on a revoked CoinRithm key (HTTP 401). These classifiers give such failures
|
|
93
|
+
// a fast, NON-revivable disable with a machine-readable reason prefix the
|
|
94
|
+
// scheduler's self-heal exempts ('model_unavailable' / 'key_invalid').
|
|
95
|
+
//
|
|
96
|
+
// Permanent model errors are deterministic, so the threshold is small — 3
|
|
97
|
+
// consecutive occurrences rules out a one-off routing fluke without burning a
|
|
98
|
+
// day. Auth failures get 10: a key rotation/propagation blip should not kill
|
|
99
|
+
// an agent, but nothing recovers from an actually-revoked key.
|
|
100
|
+
export const PERMANENT_MODEL_ERROR_RE = /model_not_found|model[_ ]decommissioned|has been decommissioned|\b404\b|does not exist or you do not have access/i;
|
|
101
|
+
export const PERMANENT_MODEL_ERROR_THRESHOLD = 3;
|
|
102
|
+
export const AUTH_FAILURE_THRESHOLD = 10;
|
|
103
|
+
export const isPermanentModelError = (error) => PERMANENT_MODEL_ERROR_RE.test(error);
|
|
104
|
+
// The observe phase folds a rejected key into its required-reads skip reason
|
|
105
|
+
// as "... (HTTP 401)".
|
|
106
|
+
export const isAuthFailureSkip = (skipReason) => /HTTP 401/.test(skipReason);
|
|
87
107
|
// Returns a disable reason if any kill-switch condition is tripped, else null.
|
|
88
108
|
export function checkKillSwitch(spec, state) {
|
|
89
109
|
const ks = spec.killSwitch;
|
package/dist/agent/strictLint.js
CHANGED
|
@@ -25,8 +25,20 @@ const ALLOWED_KEYS = {
|
|
|
25
25
|
"capabilities",
|
|
26
26
|
"include",
|
|
27
27
|
"watchlist",
|
|
28
|
+
// Load-bearing since OKF v2 (skill.ts builds the full TriggerPolicy from
|
|
29
|
+
// it) but was missing here, so any bundle actually SETTING it got an
|
|
30
|
+
// unknown_key lint — the knob existed and was unreachable (audit rank 10).
|
|
31
|
+
"triggerPolicy",
|
|
28
32
|
],
|
|
29
33
|
trigger: ["cadence", "timezone", "events"],
|
|
34
|
+
triggerPolicy: [
|
|
35
|
+
"mode",
|
|
36
|
+
"skipLlmWhenNoTrigger",
|
|
37
|
+
"alwaysManageOpenPositions",
|
|
38
|
+
"maxLlmCallsPerHour",
|
|
39
|
+
"debounceMinutes",
|
|
40
|
+
"pmEvalCooldownMinutes",
|
|
41
|
+
],
|
|
30
42
|
model: ["provider", "name", "baseUrl"],
|
|
31
43
|
risk: [
|
|
32
44
|
"maxLeverage",
|
|
@@ -107,6 +119,7 @@ export function strictLint(raw) {
|
|
|
107
119
|
lintKeys("$root", raw, issues);
|
|
108
120
|
for (const block of [
|
|
109
121
|
"trigger",
|
|
122
|
+
"triggerPolicy",
|
|
110
123
|
"model",
|
|
111
124
|
"risk",
|
|
112
125
|
"limits",
|
package/dist/agent/templates.js
CHANGED
|
@@ -91,6 +91,12 @@ export function buildAgentObject(name, preset) {
|
|
|
91
91
|
trigger: { cadence: p.cadence, timezone: "UTC" },
|
|
92
92
|
model: { provider: "anthropic", name: "claude-sonnet-4-6" },
|
|
93
93
|
venues: ["futures"],
|
|
94
|
+
// Without `indicators` the event_driven gate has no setups to fire on and
|
|
95
|
+
// a fresh flat agent heartbeats forever with ZERO model calls (audit
|
|
96
|
+
// 2026-08-19: every scaffold was born dormant). Optional extras a user
|
|
97
|
+
// can add: "universe_scan" (top-movers discovery beyond the watchlist)
|
|
98
|
+
// and "news" (catalyst context for its coins).
|
|
99
|
+
capabilities: ["indicators"],
|
|
94
100
|
risk: {
|
|
95
101
|
maxLeverage: p.leverage,
|
|
96
102
|
perTradeMarginMusd: p.margin,
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface ObjectiveConfig {
|
|
|
61
61
|
secondary: string[];
|
|
62
62
|
horizon?: string;
|
|
63
63
|
}
|
|
64
|
-
export declare const ALLOWED_CAPABILITIES: readonly ["websearch", "indicators", "news"];
|
|
64
|
+
export declare const ALLOWED_CAPABILITIES: readonly ["websearch", "indicators", "news", "universe_scan"];
|
|
65
65
|
export type Capability = (typeof ALLOWED_CAPABILITIES)[number];
|
|
66
66
|
export interface AgentSpec {
|
|
67
67
|
name: string;
|
|
@@ -106,6 +106,7 @@ export interface WatchEntry {
|
|
|
106
106
|
sentimentBullishPct?: number;
|
|
107
107
|
freshness?: Freshness;
|
|
108
108
|
indicators?: IndicatorSet;
|
|
109
|
+
discovered?: boolean;
|
|
109
110
|
}
|
|
110
111
|
export interface OpenPosition {
|
|
111
112
|
venue: Venue;
|
|
@@ -197,6 +198,12 @@ export interface Observation {
|
|
|
197
198
|
newClosedTrades: Array<Record<string, unknown>>;
|
|
198
199
|
polledBeforeWrite: boolean;
|
|
199
200
|
news?: NewsItem[];
|
|
201
|
+
universeMovers?: Array<{
|
|
202
|
+
symbol: string;
|
|
203
|
+
name?: string;
|
|
204
|
+
change24hPct?: number;
|
|
205
|
+
priceUsd?: number;
|
|
206
|
+
}>;
|
|
200
207
|
}
|
|
201
208
|
export type ProposedAction = {
|
|
202
209
|
type: "futures_open";
|
|
@@ -290,6 +297,8 @@ export interface RunState {
|
|
|
290
297
|
llmCallTimestamps?: number[];
|
|
291
298
|
lastLlmCallAt?: number;
|
|
292
299
|
lastTriggerFingerprint?: string;
|
|
300
|
+
consecutivePermanentModelErrors?: number;
|
|
301
|
+
consecutiveAuthFailures?: number;
|
|
293
302
|
journal?: Array<{
|
|
294
303
|
at: string;
|
|
295
304
|
did: string;
|