@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/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) {
@@ -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?: {
@@ -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
  }
@@ -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">;
@@ -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
@@ -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
- const cr = await client.candles(coinId, INDICATOR_RANGE, trace);
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 watchlist coins, fed into the decide prompt as a market-catalyst
455
- // layer the price chart can't show. One cached call; degrades to no news on
456
- // failure (never blocks a cycle).
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
- if (wantNews && spec.risk.watchlist.length > 0) {
459
- const nr = await client.agentNews({ coins: spec.risk.watchlist.join(","), limit: 8, hours: 48 }, trace);
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.
@@ -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
- `- watchlist (spot + futures use ONLY these): ${r.watchlist.join(", ")}`,
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,
@@ -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
+ };
@@ -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
+ };
@@ -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
@@ -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;
@@ -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;
@@ -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",
@@ -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,
@@ -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;