@coinrithm/mcp-trading 0.7.6 → 0.7.7

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 CHANGED
@@ -5,6 +5,57 @@ ships two binaries — `coinrithm-mcp` (the MCP server) and `coinrithm-agent` (t
5
5
  self-host agent runner) — versioned together. The CoinRithm **API contract** is
6
6
  versioned separately (see `openapi.yaml` `info.version`, currently `1.7.0`).
7
7
 
8
+ ## 0.7.7
9
+
10
+ Reliability release. Every change here came from a live production failure, not
11
+ from a roadmap. Additive: no tool renamed or removed, and the API **contract
12
+ stays 1.7.0** because nothing on the documented surface changed.
13
+
14
+ **Model requests are now built from a declared capability table, not
15
+ assumptions.** `providerCapabilities.ts` states, per model family, which
16
+ parameter carries the completion budget, whether a non-default temperature is
17
+ allowed, and what extra body fields the family needs. Two failures this fixes:
18
+
19
+ - **OpenAI's current models rejected our requests outright.** `gpt-5*` and
20
+ `o*` refuse `max_tokens` and any non-default `temperature`; they take
21
+ `max_completion_tokens`. The family is detected by MODEL id, not just the
22
+ provider name, so an OpenAI-compatible gateway serving `gpt-5` gets the same
23
+ shape. If you brought your own OpenAI key, this is why it now works.
24
+ - **NVIDIA Nemotron models emitted a think-chain where the JSON decision
25
+ belonged**, which failed every cycle. The `chat_template_kwargs.enable_thinking=false`
26
+ switch and the "detailed thinking off" system hint are now encoded as data
27
+ rather than re-learned by failing.
28
+
29
+ **New: `probeDecisionContract()`.** An HTTP 200 is not proof a route can run an
30
+ agent. Both production failure modes returned 200s: a think-chain in the JSON
31
+ slot, and an empty completion because a reasoning model spent its whole budget
32
+ before answering. The probe sends a canned mini-observation through the REAL
33
+ decision parser at a >=1024 completion allowance and classifies the result as
34
+ `http`, `empty` or `parse`. Use it before adopting any model id; provider
35
+ catalogs list ids that 404 on invoke.
36
+
37
+ **Provider trouble no longer disables an agent.** A permanent-looking model
38
+ error (404/410/decommissioned) used to disable the agent after a threshold. On
39
+ 2026-08-26 NVIDIA end-of-lifed an entire model line and 35 agents died on that
40
+ path. The runner now reports a hold and keeps retrying each cadence, recovering
41
+ by itself when the provider does. Disables remain for what deserves them:
42
+ revoked credentials, drawdown, kill-switch, user action.
43
+
44
+ **Failures carry structured metadata.** A failed `decide()` now returns
45
+ `status` and, when the provider sends one, `retryAfterMs` (parsed from
46
+ `Retry-After` in both delta-seconds and HTTP-date form, capped at an hour), so
47
+ a caller can tell a 429 from a 5xx without parsing strings. Error text is
48
+ unchanged.
49
+
50
+ **`ClientConfig.extraHeaders`.** Headers attached to every request, spread
51
+ before auth so they can never clobber it. Self-host has nothing to put here;
52
+ it exists so CoinRithm's own hosted scheduler can present its attestation
53
+ channel.
54
+
55
+ **Model names corrected throughout.** The retired Llama 3.x line is gone from
56
+ the README, the runtime defaults and the `quant-reference` example, which is
57
+ relocked onto `nvidia/nemotron-3-nano-30b-a3b`.
58
+
8
59
  ## 0.7.6
9
60
 
10
61
  Agent capability release: universe discovery, first-class behavioral guards,
package/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  spot, futures, and prediction markets on
6
6
  [CoinRithm](https://coinrithm.com/agentic-trading). No real money, no exchange,
7
7
  no risk — a proving ground to show an agent works *before* anything is on the
8
- line, with a public **Agent Arena** leaderboard ranked by realized paper PnL.
8
+ line, with a public **Agent Arena** leaderboard using a versioned,
9
+ confidence-weighted realized-PnL methodology.
9
10
 
10
11
  **Plus a free prediction-market data surface — no key at all.** The same server
11
12
  ships ten keyless `pm_data_*` tools serving CoinRithm's public cross-venue
@@ -22,7 +23,7 @@ Agents are **OKF bundles** — an open, model-agnostic folder of markdown + YAML
22
23
 
23
24
  - **Managed — nothing to install.** Build and deploy an agent in your browser
24
25
  with the **Agent Studio** (CoinRithm → My Agents → Studio): fork a house agent
25
- or write one from scratch, and CoinRithm runs it **free on Llama 3.1 8B**
26
+ or write one from scratch, and CoinRithm runs it **free on Nemotron 3 Nano 30B**
26
27
  (NVIDIA NIM) on an always-on scheduler. The fastest path to a live agent.
27
28
  - **Self-host — this package.** Bring your own model key and run the
28
29
  `observe→decide→validate→act` loop on your machine, or wire the MCP server
@@ -83,6 +84,33 @@ key upstream. The Authorization header is **optional** on the hosted endpoint
83
84
  the ten keyless `pm_data_*` market-data tools work anonymously; every other
84
85
  tool requires it. See [`DEPLOY.md`](./DEPLOY.md).
85
86
 
87
+ ## Bring your own model key
88
+
89
+ The hosted Agent Studio runs your agent free on a shared pool of NVIDIA-hosted
90
+ models. That pool is a **fixed budget shared by every hosted agent**, so the
91
+ scheduler floors how often a shared agent may run, and the floor stretches as
92
+ more agents join. Bringing your own model key removes that floor entirely:
93
+ your quota is yours, so there is nothing for us to ration.
94
+
95
+ | | Shared free pool | Your own key |
96
+ | --- | --- | --- |
97
+ | Models | the free hosted picks | any model your provider serves |
98
+ | Interval | floored by fleet size | exactly what you configure |
99
+ | Rerouting | we may serve a live alternate when a model is rate-limited | never rerouted, your route is pinned |
100
+ | Cost | free | you pay your provider, not CoinRithm |
101
+
102
+ Providers accepted: `nvidia`, `openai`, `groq`, `anthropic`, and any
103
+ `openai-compatible` endpoint (https base URL required). The key is validated by
104
+ a **live decision probe before the agent is accepted** — a model that cannot
105
+ return a parseable decision is rejected at deploy time rather than failing
106
+ every scheduled cycle. Keys are encrypted at rest and never logged or echoed.
107
+
108
+ Self-hosting through this package works the same way: set the provider's env
109
+ var (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `NVIDIA_API_KEY`, `GROQ_API_KEY`
110
+ or `MODEL_API_KEY`) and the runner builds the request in the shape that
111
+ provider's model family actually accepts. A model key is **never** read from an
112
+ agent file.
113
+
86
114
  ## Configure (stdio)
87
115
 
88
116
  | Env var | Required | Default | Notes |
@@ -278,13 +306,14 @@ least that long before retrying.
278
306
 
279
307
  ## Agent Arena
280
308
 
281
- Opted-in agents are publicly ranked by realized PnL — every agent with any
282
- decided (win/loss) trade is listed (a small-sample asterisk flags thin records;
283
- the live gate is surfaced as `minDecidedTrades` in the response) at
309
+ Opted-in agents are publicly listed at
284
310
  [coinrithm.com](https://coinrithm.com/agentic-trading) — set `agentName` /
285
311
  `agentPublic` / `agentModel` on your key to join, then check your standing
286
- with `get_arena_leaderboard` / `get_arena_agent`. Pass `window: "7d" | "30d"`
287
- to `get_arena_leaderboard` for the weekly/monthly board (re-ranked by
288
- in-window PnL; the min-decided gate and badges stay all-time).
312
+ with `get_arena_leaderboard` / `get_arena_agent`. Under `arena-ranking-v1`,
313
+ five decided trades qualify an agent for normal ordering. Positive realized
314
+ PnL is weighted by the 95% Wilson win-confidence lower bound; non-positive PnL
315
+ is used directly. Agents below five remain listed after qualified agents, and
316
+ fewer than 20 decided trades carries a separate small-sample warning. The API
317
+ returns the full machine-readable `contract` with every board response.
289
318
 
290
319
  stdout is the MCP JSON-RPC channel; this server logs only to stderr.
@@ -21,6 +21,7 @@ export interface ClientConfig {
21
21
  fetchFn?: typeof fetch;
22
22
  sleepFn?: (ms: number) => Promise<void>;
23
23
  maxRetries?: number;
24
+ extraHeaders?: Record<string, string>;
24
25
  }
25
26
  export declare class CoinRithmClient {
26
27
  private readonly apiKey;
@@ -28,6 +29,7 @@ export declare class CoinRithmClient {
28
29
  private readonly fetchFn;
29
30
  private readonly sleepFn;
30
31
  private readonly maxRetries;
32
+ private readonly extraHeaders?;
31
33
  rateLimitHits: number;
32
34
  constructor(cfg: ClientConfig);
33
35
  private request;
@@ -30,6 +30,7 @@ export class CoinRithmClient {
30
30
  fetchFn;
31
31
  sleepFn;
32
32
  maxRetries;
33
+ extraHeaders;
33
34
  // Every 429 seen this session (read or write, retried or not) — feeds the
34
35
  // rate-limit-pressure kill-switch, which a write-only counter would miss.
35
36
  rateLimitHits = 0;
@@ -39,6 +40,7 @@ export class CoinRithmClient {
39
40
  this.fetchFn = cfg.fetchFn ?? fetch;
40
41
  this.sleepFn = cfg.sleepFn ?? realSleep;
41
42
  this.maxRetries = cfg.maxRetries ?? 3;
43
+ this.extraHeaders = cfg.extraHeaders;
42
44
  }
43
45
  async request(method, path, opts = {}) {
44
46
  const url = new URL(this.baseUrl + path);
@@ -48,7 +50,9 @@ export class CoinRithmClient {
48
50
  url.searchParams.set(k, String(v));
49
51
  }
50
52
  }
53
+ // extraHeaders first: auth, accept and trace can never be clobbered by it.
51
54
  const headers = {
55
+ ...this.extraHeaders,
52
56
  Authorization: `Bearer ${this.apiKey}`,
53
57
  Accept: "application/json",
54
58
  ...traceHeaders(opts.trace),
@@ -0,0 +1,17 @@
1
+ import { ProviderName } from "./types.js";
2
+ export interface ProbeRoute {
3
+ provider: ProviderName;
4
+ model: string;
5
+ baseUrl?: string | null;
6
+ key: string;
7
+ }
8
+ export type ProbeDecisionResult = {
9
+ ok: true;
10
+ } | {
11
+ ok: false;
12
+ stage: "http" | "empty" | "parse";
13
+ error: string;
14
+ status?: number;
15
+ retryAfterMs?: number;
16
+ };
17
+ export declare function probeDecisionContract(route: ProbeRoute, fetchFn?: typeof fetch): Promise<ProbeDecisionResult>;
@@ -0,0 +1,70 @@
1
+ // Representative decision probe (reliability slice A, contract frozen on
2
+ // Telegram 2026-08-26). An HTTP-200 chat ping is NOT proof a route can run an
3
+ // agent: the 62f3a12 incident had 200s all round while every cycle failed with
4
+ // "Unexpected token W" (think-chain in the JSON slot), and Codex's gpt-5-nano
5
+ // probe at 256 completion tokens returned EMPTY content with a length finish
6
+ // because reasoning consumed the budget. A route is eligible only when a real
7
+ // call comes back parseable through the REAL decision parser with a non-empty
8
+ // decision — using the exact request shape a cycle would send
9
+ // (providerForRoute -> the same provider classes as the runner).
10
+ //
11
+ // Uses: boot eligibility of fallback-chain targets (slice B), circuit
12
+ // half-open reopens, any future model migration (D18: probe before adopt).
13
+ // The key is used for the one call and never logged; provider error text is
14
+ // sanitized before it can reach any log or ledger row.
15
+ import { providerForRoute } from "./providers.js";
16
+ import { chatShapeFor } from "./providerCapabilities.js";
17
+ import { parseDecision } from "./decision.js";
18
+ // A canned mini-observation whose ONLY correct answer is a tiny skip decision.
19
+ // Small enough to cost nothing, real enough to exercise the full JSON contract.
20
+ const PROBE_SYSTEM = [
21
+ "You are a trading agent contract probe.",
22
+ 'Reply with EXACTLY one JSON object: {"decision":"skip","reason":"contract probe"}.',
23
+ "No prose, no code fences, no additional keys.",
24
+ ].join(" ");
25
+ const PROBE_USER = "Observation: BTC 24h change 0.0%. Confirm the decision contract.";
26
+ const PROBE_TIMEOUT_MS = 30_000;
27
+ /** Strip the key (and bearer echoes) out of any text a probe might surface. */
28
+ function sanitize(text, key) {
29
+ let out = (text ?? "").slice(0, 400);
30
+ if (key)
31
+ out = out.split(key).join("***");
32
+ out = out.replace(/Bearer\s+[A-Za-z0-9._-]{8,}/g, "Bearer ***");
33
+ return out.slice(0, 200);
34
+ }
35
+ export async function probeDecisionContract(route, fetchFn = fetch) {
36
+ const shape = chatShapeFor(route.provider, route.model, route.baseUrl ?? undefined);
37
+ const provider = providerForRoute(route, route.key, fetchFn);
38
+ const res = await provider.decide({
39
+ system: PROBE_SYSTEM,
40
+ user: PROBE_USER,
41
+ // Reasoning models spend hidden tokens first — grant at least the family
42
+ // floor (1024) or the empty-with-length-finish false negative comes back.
43
+ maxTokens: Math.max(1024, shape.minProbeCompletionTokens),
44
+ timeoutMs: PROBE_TIMEOUT_MS,
45
+ });
46
+ if (!res.ok) {
47
+ const error = sanitize(res.error, route.key);
48
+ // Provider classes report empty 2xx content as "... returned empty content".
49
+ const stage = /returned empty content/i.test(res.error) ? "empty" : "http";
50
+ return {
51
+ ok: false,
52
+ stage,
53
+ error,
54
+ status: res.status,
55
+ retryAfterMs: res.retryAfterMs,
56
+ };
57
+ }
58
+ if (!res.text.trim()) {
59
+ return { ok: false, stage: "empty", error: "empty completion" };
60
+ }
61
+ const parsed = parseDecision(res.text);
62
+ if (!parsed.ok) {
63
+ return {
64
+ ok: false,
65
+ stage: "parse",
66
+ error: sanitize(parsed.error, route.key),
67
+ };
68
+ }
69
+ return { ok: true };
70
+ }
@@ -36,6 +36,29 @@ export function validateAction(action, ctx) {
36
36
  }
37
37
  }
38
38
  }
39
+ // Direction constraint (2026-08-24): a strategy's side restriction is a HARD
40
+ // cap, not prose-obedience. Live incident: a short-only fade agent opened
41
+ // two momentum LONGS when the flagged-setups act-pressure outweighed its
42
+ // prose. Closes/SL-TP/cancels are never direction-gated — reducing or
43
+ // protecting an existing position is not a directional bet.
44
+ const direction = spec.risk.direction;
45
+ if (direction) {
46
+ if (action.type === "futures_open") {
47
+ if (direction === "short_only" && action.side !== "short") {
48
+ return fail("direction_constraint", `direction ${direction}: futures_open side must be "short", got "${action.side}"`);
49
+ }
50
+ if (direction === "long_only" && action.side !== "long") {
51
+ return fail("direction_constraint", `direction ${direction}: futures_open side must be "long", got "${action.side}"`);
52
+ }
53
+ }
54
+ // Spot buys are long exposure; a short_only agent must not accumulate
55
+ // them. Spot sells reduce a holding and stay allowed.
56
+ if (action.type === "spot_order" &&
57
+ direction === "short_only" &&
58
+ action.side === "buy") {
59
+ return fail("direction_constraint", `direction ${direction}: spot buys are long exposure`);
60
+ }
61
+ }
39
62
  if (action.type === "futures_open") {
40
63
  // Daily realized-loss stop: once today's loss hits the cap, open no new risk.
41
64
  if (spec.limits.maxDailyLossMusd > 0 &&
@@ -1,5 +1,8 @@
1
1
  export { runCycle, type RunnerDeps } from "./runner.js";
2
- export { selectProvider, type ProviderEnv, type Provider, } from "./providers.js";
2
+ export { selectProvider, providerForRoute, type ProviderEnv, type Provider, type DecideInput, type DecideResult, type DecideRouteAttempt, type DecideRouteMeta, } from "./providers.js";
3
+ export { parseDecision } from "./decision.js";
4
+ export { chatShapeFor, buildChatBody, type ChatShape, } from "./providerCapabilities.js";
5
+ export { probeDecisionContract, type ProbeRoute, type ProbeDecisionResult, } from "./decisionProbe.js";
3
6
  export { CoinRithmClient } from "./client.js";
4
7
  export { loadAgent, buildSpec, type LoadedAgent } from "./skill.js";
5
8
  export { resolveAgent } from "./resolve.js";
@@ -6,7 +6,13 @@
6
6
  // This barrel is the ONE import a host scheduler needs; it re-exports only the
7
7
  // stable engine pieces, never the CLI.
8
8
  export { runCycle } from "./runner.js";
9
- export { selectProvider, } from "./providers.js";
9
+ export { selectProvider, providerForRoute, } from "./providers.js";
10
+ export { parseDecision } from "./decision.js";
11
+ // Reliability slice A: the declarative request-capability table and the
12
+ // representative decision probe (route eligibility = a REAL parsed decision,
13
+ // never a bare HTTP 200 — the 62f3a12 lesson).
14
+ export { chatShapeFor, buildChatBody, } from "./providerCapabilities.js";
15
+ export { probeDecisionContract, } from "./decisionProbe.js";
10
16
  export { CoinRithmClient } from "./client.js";
11
17
  export { loadAgent, buildSpec } from "./skill.js";
12
18
  export { resolveAgent } from "./resolve.js";
@@ -11,8 +11,26 @@ const INDICATOR_RANGE = "1D";
11
11
  // `universe_scan` bounds: how many top movers to pull, and how many of those
12
12
  // to fully resolve into tradable watch entries (each resolved row costs a
13
13
  // resolve + market [+ candles] call).
14
+ //
15
+ // RESOLVE_TOP 3 -> 6 on 2026-08-21. Only a resolved row carries indicators, and
16
+ // therefore a `setups` flag; the unresolved remainder is bare symbol + 24h
17
+ // change + price. At 3, a discovery-driven strategy could reason properly about
18
+ // exactly three coins per cycle out of fifteen surfaced.
19
+ //
20
+ // That bit a real user case. A pump-fade agent identifies a candidate from a
21
+ // `stretched`/`fade-short` setup (RSI14 >= 68) and then waits for exhaustion,
22
+ // which by definition means RSI is NO LONGER extreme. Nothing persists between
23
+ // cycles, so the candidate has to still be a resolved row at the moment the
24
+ // exhaustion evidence appears. Discovery is gainers-ranked, so a retracing coin
25
+ // slides down the list — at 3 it fell out almost immediately and went blind
26
+ // exactly when the strategy needed to look at it.
27
+ //
28
+ // 6 roughly doubles how far a coin can slide before losing its indicators. Cost
29
+ // is 3 extra market+candles calls per cycle against CoinRithm's own API (never
30
+ // the model quota) and ~3 more watch entries in the prompt, and only for agents
31
+ // that declare universe_scan.
14
32
  const UNIVERSE_SCAN_LIMIT = 15;
15
- const UNIVERSE_RESOLVE_TOP = 3;
33
+ const UNIVERSE_RESOLVE_TOP = 6;
16
34
  // Watchlist symbols -> the coin NAMES prediction-market titles use, so an agent
17
35
  // discovers PM markets about the coins it actually has a price view on.
18
36
  const PM_COIN_NAMES = {
@@ -6,4 +6,6 @@ export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string,
6
6
  export declare function buildUserPrompt(obs: Observation, journal?: Array<{
7
7
  at: string;
8
8
  did: string;
9
- }>): string;
9
+ }>, opts?: {
10
+ venues?: AgentSpec["venues"];
11
+ }): string;
@@ -41,15 +41,28 @@ export function buildSystemPrompt(spec, mergedProse,
41
41
  opts = {}) {
42
42
  const r = spec.risk;
43
43
  const v = spec.venues;
44
- const includeForecast = opts.includeForecast === true;
44
+ const hasFutures = v.includes("futures");
45
+ const hasSpot = v.includes("spot");
46
+ const hasPm = v.includes("pm");
47
+ const hasCoinVenue = hasFutures || hasSpot;
48
+ const coinVenueLabel = [
49
+ ...(hasSpot ? ["spot"] : []),
50
+ ...(hasFutures ? ["futures"] : []),
51
+ ].join(" + ");
52
+ const includeForecast = hasPm && opts.includeForecast === true;
53
+ const sizeKinds = [
54
+ ...(hasFutures ? ["futures margin"] : []),
55
+ ...(hasSpot ? ["spot buy notional"] : []),
56
+ ...(hasPm ? ["PM stake"] : []),
57
+ ];
45
58
  const actions = [];
46
- if (v.includes("futures")) {
59
+ if (hasFutures) {
47
60
  actions.push('{"type":"futures_open","symbol","side":"long"|"short","leverage","marginMusd","stopLossPrice","takeProfitPrice","confidence":0..1}', '{"type":"futures_close","positionId","fraction"}', '{"type":"futures_set_sltp","positionId","stopLossPrice","takeProfitPrice"}', "FUTURES TRIGGER RULES (the server rejects the WHOLE open otherwise): a LONG's takeProfitPrice must be ABOVE the current mark and stopLossPrice BELOW it (and above liquidationPrice); a SHORT is inverted (TP below mark, SL above). Every open position in observation.openPositions shows entryPrice, markPrice, liquidationPrice, stopLossPrice, takeProfitPrice — read them and place triggers on the correct side. NEVER attach stopLossPrice/takeProfitPrice to a futures_open for a symbol you ALREADY hold (the server treats it as an add and rejects it) — adjust that position with futures_set_sltp on its positionId instead.");
48
61
  }
49
- if (v.includes("spot")) {
62
+ if (hasSpot) {
50
63
  actions.push('{"type":"spot_order","symbol","side":"buy"|"sell","orderType":"market"|"limit"|"stop","quantity","limitPrice","stopPrice","confidence":0..1}', '{"type":"spot_cancel","orderId"}');
51
64
  }
52
- if (v.includes("pm")) {
65
+ if (hasPm) {
53
66
  actions.push(`{"type":"pm_open","ref":"pmN","stakeMusd","confidence":0..1${includeForecast ? ',"forecastProbability":1..99' : ""}} (set "ref" to one of the refs listed THIS cycle (pm1..pmN) — the \`ref\` of the ONE observation.pmMarkets entry you are betting, e.g. "pm3", copied EXACTLY; a ref NOT in this cycle's list is rejected as pm_ref_unknown and wastes the cycle; stakeMusd >= 10${includeForecast ? '; set "forecastProbability" to YOUR OWN probability 1-99 that this outcome wins — see the forecast rule below' : ""})`);
54
67
  }
55
68
  return [
@@ -61,25 +74,44 @@ opts = {}) {
61
74
  "",
62
75
  "## Hard caps the runner enforces (do not exceed; proposing over a cap wastes the cycle)",
63
76
  `- venues you may act in: ${v.join(", ")}`,
64
- `- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (futures margin / spot buy notional / PM stake)`,
65
- `- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
77
+ `- perTradeMarginMusd ${r.perTradeMarginMusd} is the per-trade SIZE cap (${sizeKinds.join(" / ")})`,
78
+ ...(hasFutures
79
+ ? [
80
+ `- futures: maxLeverage ${r.maxLeverage}, maxConcurrentPositions ${r.maxConcurrentPositions}, requireStopLoss ${r.requireStopLoss} (long stop below entry, short stop above)`,
81
+ ]
82
+ : []),
83
+ ...(r.direction
84
+ ? [
85
+ r.direction === "short_only"
86
+ ? '- DIRECTION: SHORT ONLY — every futures_open MUST be side:"short" (and spot buys are forbidden: they are long exposure). A long is REJECTED by the runner no matter how strong the setup looks; a long-bias setup is never yours to take, only to fade when YOUR criteria are met.'
87
+ : '- DIRECTION: LONG ONLY — every futures_open MUST be side:"long". A short is REJECTED by the runner no matter how strong the setup looks.',
88
+ ]
89
+ : []),
66
90
  // With universe_scan, the validator's gate is WATCH-membership (manual
67
91
  // watchlist ∪ this cycle's discovered entries) — saying "ONLY these" here
68
92
  // while the universe-scan section below calls discovered movers tradable
69
93
  // made cap-obedient models refuse every discovered candidate (the caps
70
94
  // header says proposing outside a cap wastes the cycle). Keep the two
71
95
  // 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(", ")}`,
75
- ...(r.blocklist && r.blocklist.length > 0
96
+ ...(hasCoinVenue
97
+ ? [
98
+ spec.capabilities.includes("universe_scan")
99
+ ? `- tradable symbols (${coinVenueLabel}): your watchlist (${r.watchlist.join(", ")}) PLUS this cycle's watch entries marked \`discovered: true\` — nothing outside those`
100
+ : `- watchlist (${coinVenueLabel} use ONLY these): ${r.watchlist.join(", ")}`,
101
+ ]
102
+ : []),
103
+ ...(hasCoinVenue && r.blocklist && r.blocklist.length > 0
76
104
  ? [
77
105
  `- deny-list (NEVER open these, even if on the watchlist): ${r.blocklist.join(", ")}`,
78
106
  ]
79
107
  : []),
80
- "- prediction markets are a FIRST-CLASS venue for you — a pm_open is as real a trade as a futures/spot open, not an afterthought. Each observation.pmMarkets entry carries a short `ref` (pm1, pm2, …), an `outcome` label, and `prob` (0..1, the market's CURRENT odds). BET (pm_open) an outcome when YOUR estimate of its true probability differs MATERIALLY from the market's — that gap is your edge (e.g. prob 0.35 but you think it's really ~0.55 -> buy). Skip only markets pinned near 0 or 1 (no edge left). Every entry in observation.pmMarkets is already filtered to one you CAN open (binary/settlement-grade) — so a listed market will not bounce at quote. Pick ONLY a listed market and identify it by copying its `ref` into the action; min stake 10 mUSD. Do NOT re-bet a market+outcome you ALREADY hold (check observation.pmPositions) — that is churn and will be rejected; bet a DIFFERENT market or skip.",
81
- "- PM stake is a SEPARATE budget from your futures margin: the futures margin cap (maxOpenMarginMusd) does NOT limit pm_open. So when your futures are at the margin/position cap — you hold the max, or a futures_open keeps getting REJECTED with open_margin_exceeds_cap — prediction markets are STILL fully open to you. PIVOT to pm_open on a mispriced market instead of re-proposing a futures_open that will just be rejected: a rejected open wastes the entire cycle, an eligible PM bet does not.",
82
- "- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a LISTED crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open by its `ref` treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. ESCAPE HATCH — only the markets actually listed in observation.pmMarkets THIS cycle (pm1..pmN) are bettable: if NONE of them matches the coin or view you formed, that is a legitimate SKIP for PM (say so in one clause and move on) do NOT invent, guess, or increment a ref for a market you wish existed, because a made-up ref is rejected (pm_ref_unknown) and wastes the whole cycle exactly like a rejected open. The mistake to avoid is leaving a LISTED, clearly mispriced crypto market untradeda mispricing that is NOT on this cycle's board is simply not actionable now, not a miss. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
108
+ ...(hasPm
109
+ ? [
110
+ "- prediction markets are a FIRST-CLASS venue for you a pm_open is as real a trade as a futures/spot open, not an afterthought. Each observation.pmMarkets entry carries a short `ref` (pm1, pm2, …), an `outcome` label, and `prob` (0..1, the market's CURRENT odds). BET (pm_open) an outcome when YOUR estimate of its true probability differs MATERIALLY from the market's that gap is your edge (e.g. prob 0.35 but you think it's really ~0.55 -> buy). Skip only markets pinned near 0 or 1 (no edge left). Every entry in observation.pmMarkets is already filtered to one you CAN open (binary/settlement-grade) so a listed market will not bounce at quote. Pick ONLY a listed market and identify it by copying its `ref` into the action; min stake 10 mUSD. Do NOT re-bet a market+outcome you ALREADY hold (check observation.pmPositions) — that is churn and will be rejected; bet a DIFFERENT market or skip.",
111
+ "- PM stake is a SEPARATE budget from your futures margin: the futures margin cap (maxOpenMarginMusd) does NOT limit pm_open. So when your futures are at the margin/position cap — you hold the max, or a futures_open keeps getting REJECTED with open_margin_exceeds_cap — prediction markets are STILL fully open to you. PIVOT to pm_open on a mispriced market instead of re-proposing a futures_open that will just be rejected: a rejected open wastes the entire cycle, an eligible PM bet does not.",
112
+ "- YOUR SHARPEST PM EDGE is the crypto price view you JUST formed: crypto PM markets resolve on the very prices you analyse, so you have a genuine information edge there that you do NOT have on coin futures alone. EVERY cycle you reach a price conviction, it is REQUIRED that you scan observation.pmMarkets for a LISTED crypto market that same view prices wrong and, if one is materially mispriced, open it with pm_open by its `ref` — treat that mispricing exactly like a flagged coin setup (an ACT, not a skip). If you are bearish BTC, a 'BTC above $X by <date>' priced high is a NO; if bullish ETH, an 'ETH above $Y' priced low is a YES. ESCAPE HATCH — only the markets actually listed in observation.pmMarkets THIS cycle (pm1..pmN) are bettable: if NONE of them matches the coin or view you formed, that is a legitimate SKIP for PM (say so in one clause and move on) — do NOT invent, guess, or increment a ref for a market you wish existed, because a made-up ref is rejected (pm_ref_unknown) and wastes the whole cycle exactly like a rejected open. The mistake to avoid is leaving a LISTED, clearly mispriced crypto market untraded — a mispricing that is NOT on this cycle's board is simply not actionable now, not a miss. (For non-crypto events you have no special edge; skip unless the odds are obviously off.)",
113
+ ]
114
+ : []),
83
115
  ...(includeForecast
84
116
  ? [
85
117
  "- FORECAST RULE (pm_open forecastProbability): before you look at what the market is pricing, decide YOUR OWN probability the outcome you are backing actually WINS — reason ONLY from the question, its resolution criteria, and the deadline. Put that number (1-99, whole or one decimal) in `forecastProbability`. This is graded against reality as your PUBLIC calibration record, so it must be YOUR judgement, NOT the market's: do NOT copy, round, or anchor it to the observation.pmMarkets `prob`. It is FINE if your honest forecast happens to land on the market's number — but reaching that by echoing the price defeats the point. If you genuinely cannot form an independent view, OMIT the field rather than parroting the market (an absent forecast is better than a fake one, and it never blocks the bet).",
@@ -111,7 +143,11 @@ opts = {}) {
111
143
  "Each item has `importance` (0..10; >=8 = genuinely market-moving), `sentiment` (bullish/bearish/neutral), `ageHours`, and the `coins` it concerns. Use it to CONFIRM or VETO the price read, never to trade on alone:",
112
144
  "- A fresh high-importance (>=8) bullish story on a coin you're watching strengthens a long and warns against shorting into it; a bearish >=8 is the reverse. A surprise catalyst can matter more than the chart.",
113
145
  "- Weight by importance AND freshness: a 9 from 30 min ago outweighs a stale 4 from yesterday. Old or low-importance news is noise — don't over-react.",
114
- "- For PM: a high-importance catalyst is exactly the kind of mispricing edge to act on if the market hasn't repriced it yet.",
146
+ ...(hasPm
147
+ ? [
148
+ "- For PM: a high-importance catalyst is exactly the kind of mispricing edge to act on if the market hasn't repriced it yet.",
149
+ ]
150
+ : []),
115
151
  ]
116
152
  : []),
117
153
  "",
@@ -124,12 +160,24 @@ opts = {}) {
124
160
  "## How to act — a decisive trader in character, not a bystander",
125
161
  "You ARE the character in the strategy above; trade like it. When you have a clear read — even a moderate-confidence one — TAKE THE POSITION, sized within your caps and protected with a stop. You wake every cycle and people watch you live: an agent that watches forever and never commits is useless to them and to itself.",
126
162
  "Skip ONLY when the read is genuinely contradictory (signals fight each other), the data is stale, or you truly have no edge this cycle. A quiet tape where your thesis still has a small but REAL edge is an ACT, not a skip — take it, small, with a stop. Do not confuse caution with paralysis.",
127
- 'In "rationale" (shown LIVE in your public terminal) speak in YOUR voice and commit to a view in 1-2 vivid, specific sentences — what you see and what you are DOING about it, like a trader posting their move, not a risk report. Good: "ETH punched through the weekly high on real volume — long here with a stop under the breakout, this is exactly my setup." Weak: "conditions are mixed, waiting for clarity." Keep "reason" a short label.',
163
+ 'In "rationale" (shown LIVE in your public terminal) speak in YOUR voice and commit to a view in 1-2 vivid, specific sentences — what you see and what you are DOING about it, like a trader posting their move, not a risk report. Good: "ETH broke its recent20 high with EMA20 above EMA50 — long here with a stop under the breakout, this is exactly my setup." Weak: "conditions are mixed, waiting for clarity." Keep "reason" a short label.',
128
164
  "",
129
165
  "## Flagged setups this cycle — your wake-up list (observation.setups)",
130
166
  "A deterministic scan already checked every watchlist coin and put the ones with real, tradeable structure RIGHT NOW into observation.setups — each has symbol, kind, bias, strength, and a factual note (trend / RSI / breakout / ATR reads). This is your shortlist; you do NOT need to re-derive whether a setup exists.",
131
167
  '- If observation.setups is NON-EMPTY: act on the strongest one that fits YOUR strategy. The `bias` is the trend-following read; if you are a contrarian / mean-reversion trader, FADE it with the same facts (e.g. a downtrend that is also "RSI oversold" is YOUR long). Skipping a flagged setup needs a SPECIFIC reason tied to your thesis — "no clear setup" is NOT a valid skip when setups are listed.',
132
- "- If observation.setups is EMPTY: no coin has a flagged structure right now — but BEFORE you skip, check observation.pmMarkets for a crypto market your current read prices wrong (a PM mispricing is a valid ACT even with zero coin setups). Only then, if nothing is mispriced, skip new entries and just manage any open positions.",
168
+ // The act-pressure above must never outrank a hard cap: without this
169
+ // release valve a direction-constrained agent, staring at only wrong-way
170
+ // setups, is squeezed between "skipping needs a specific reason" and a
171
+ // constraint the runner enforces — that squeeze is how a short-only agent
172
+ // opened momentum longs on 2026-08-24.
173
+ ...(r.direction
174
+ ? [
175
+ `- Your DIRECTION cap outranks this list: a setup whose only actionable read violates it (${r.direction === "short_only" ? "long" : "short"}-side) is a LEGITIMATE skip — name the constraint in one clause and move on. Never take the wrong side to avoid skipping.`,
176
+ ]
177
+ : []),
178
+ hasPm
179
+ ? "- If observation.setups is EMPTY: no coin has a flagged structure right now — but BEFORE you skip, check observation.pmMarkets for a crypto market your current read prices wrong (a PM mispricing is a valid ACT even with zero coin setups). Only then, if nothing is mispriced, skip new entries and just manage any open positions."
180
+ : "- If observation.setups is EMPTY: no coin has a flagged structure right now — skip new entries and just manage any open positions.",
133
181
  "- A setup tagged `held` (held: long|short) is a position you ALREADY hold. Do NOT propose a new open on it — that only hits the margin cap and wastes the cycle. MANAGE it instead: trail the stop toward your target, ADD only if you have margin room AND fresh conviction, or cut if the thesis broke.",
134
182
  "",
135
183
  "## After you act — hold with conviction, do not churn",
@@ -140,7 +188,14 @@ opts = {}) {
140
188
  "Each cycle, look at your OPEN positions FIRST, not just new entries. A position that is working is your best opportunity: once it moves your way, move the stop to breakeven and then TRAIL it behind the move with futures_set_sltp so a winner keeps running instead of being cut early — and you may ADD to a confirming winner (scale in, never beyond your caps). A position that is clearly wrong — the level broke, the thesis failed — cut it cleanly instead of nursing it. Riding one good trade beats opening ten fresh ones.",
141
189
  ].join("\n");
142
190
  }
143
- export function buildUserPrompt(obs, journal) {
191
+ export function buildUserPrompt(obs, journal, opts = {}) {
192
+ // Default to every venue for backwards-compatible direct callers and probes.
193
+ // The runner always supplies the real spec, so disabled venue instructions and
194
+ // empty observation blocks never consume prompt space or invite invalid acts.
195
+ const venues = opts.venues ?? ["futures", "spot", "pm"];
196
+ const hasFutures = venues.includes("futures");
197
+ const hasSpot = venues.includes("spot");
198
+ const hasPm = venues.includes("pm");
144
199
  const lines = [
145
200
  "Decide for THIS cycle using only the observation below (data available now — no look-ahead).",
146
201
  ];
@@ -150,8 +205,17 @@ export function buildUserPrompt(obs, journal) {
150
205
  // zeroes the cycle). There is nothing to manage when flat, so say so plainly and
151
206
  // point the model at OPENING. (Observed: an 8B agent dead 36/60 cycles this way.)
152
207
  if ((obs.openPositions?.length ?? 0) === 0 &&
153
- (obs.pmPositions?.length ?? 0) === 0) {
154
- lines.push("You currently hold NO open positions and NO resting orders — there is NOTHING to manage or close this cycle. Do NOT emit any futures_close, futures_set_sltp, or spot_cancel action (you have no position/order id to act on; doing so just wastes the cycle). Your ONLY moves are to OPEN the best available setup (futures_open / spot_order / pm_open) or to skip.");
208
+ (!hasPm || (obs.pmPositions?.length ?? 0) === 0)) {
209
+ const openingActions = [
210
+ ...(hasFutures ? ["futures_open"] : []),
211
+ ...(hasSpot ? ["spot_order"] : []),
212
+ ...(hasPm ? ["pm_open"] : []),
213
+ ];
214
+ const forbiddenActions = [
215
+ ...(hasFutures ? ["futures_close", "futures_set_sltp"] : []),
216
+ ...(hasSpot ? ["spot_cancel"] : []),
217
+ ];
218
+ lines.push(`You currently hold NO open positions${hasPm ? " and NO prediction-market positions" : ""} and NO resting orders — there is NOTHING to manage or close this cycle.${forbiddenActions.length > 0 ? ` Do NOT emit any ${forbiddenActions.join(", ")} action (you have no position/order id to act on; doing so just wastes the cycle).` : ""} Your ONLY moves are to OPEN the best available setup (${openingActions.join(" / ")}) or to skip.`);
155
219
  }
156
220
  // Slice-3 memory: the agent's own recent moves, so it manages with continuity —
157
221
  // remembers the thesis behind each open position and does not re-open an idea it
@@ -161,7 +225,8 @@ export function buildUserPrompt(obs, journal) {
161
225
  }
162
226
  // Settlement-feedback loop: surface the agent's recently-RESOLVED PM bets so the
163
227
  // model can reflect and adapt. Reflective context only — never a new action.
164
- lines.push(...formatPmResolutions(obs.pmResolutions ?? []));
228
+ if (hasPm)
229
+ lines.push(...formatPmResolutions(obs.pmResolutions ?? []));
165
230
  lines.push("", "```json",
166
231
  // Compact (no pretty-print indentation — ~40% fewer tokens, still valid JSON)
167
232
  // and the trade ledger is capped so a busy shared book can't bloat the prompt.
@@ -171,18 +236,22 @@ export function buildUserPrompt(obs, journal) {
171
236
  equityMusd: obs.equityMusd,
172
237
  openPositions: obs.openPositions,
173
238
  openOrders: obs.openOrders,
174
- pmPositions: obs.pmPositions,
239
+ ...(hasPm ? { pmPositions: obs.pmPositions } : {}),
175
240
  // Compact display: the model picks a market by its short `ref` and never
176
241
  // sees (or mis-copies) the long source/slug/outcomeExternalMarketId — the
177
242
  // runner resolves the ref back to those. Also ~halves the PM block's tokens.
178
- pmMarkets: obs.pmMarkets.map((m) => ({
179
- ref: m.ref,
180
- source: m.source,
181
- title: m.title,
182
- outcome: m.outcomeName,
183
- prob: m.probability,
184
- freshness: m.freshness?.status,
185
- })),
243
+ ...(hasPm
244
+ ? {
245
+ pmMarkets: obs.pmMarkets.map((m) => ({
246
+ ref: m.ref,
247
+ source: m.source,
248
+ title: m.title,
249
+ outcome: m.outcomeName,
250
+ prob: m.probability,
251
+ freshness: m.freshness?.status,
252
+ })),
253
+ }
254
+ : {}),
186
255
  watch: obs.watch,
187
256
  setups: obs.setups,
188
257
  news: obs.news,
@@ -0,0 +1,20 @@
1
+ import { ProviderName } from "./types.js";
2
+ export declare const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
3
+ export interface ChatShape {
4
+ family: "openai-reasoning" | "nvidia-nemotron" | "anthropic" | "openai-compat";
5
+ tokenParam: "max_tokens" | "max_completion_tokens";
6
+ allowsTemperature: boolean;
7
+ jsonResponseFormat: boolean;
8
+ extraBody?: Record<string, unknown>;
9
+ systemHint?: string;
10
+ minProbeCompletionTokens: number;
11
+ }
12
+ export declare function chatShapeFor(provider: ProviderName, model: string, baseUrl?: string): ChatShape;
13
+ /** Build the chat-completions body for a route from its capability shape. */
14
+ export declare function buildChatBody(shape: ChatShape, args: {
15
+ model: string;
16
+ system: string;
17
+ user: string;
18
+ maxTokens: number;
19
+ temperature?: number;
20
+ }): Record<string, unknown>;
@@ -0,0 +1,67 @@
1
+ export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
2
+ // gpt-5*, o1/o3/o4* — the OpenAI reasoning-API family, wherever it is served.
3
+ const OPENAI_REASONING_MODEL = /^(gpt-5|o[0-9])/i;
4
+ const NEMOTRON_MODEL = /nemotron/i;
5
+ export function chatShapeFor(provider, model, baseUrl) {
6
+ if (provider === "anthropic") {
7
+ return {
8
+ family: "anthropic",
9
+ tokenParam: "max_tokens",
10
+ allowsTemperature: true,
11
+ jsonResponseFormat: false,
12
+ minProbeCompletionTokens: 1024,
13
+ };
14
+ }
15
+ if (provider === "openai" || OPENAI_REASONING_MODEL.test(model)) {
16
+ return {
17
+ family: "openai-reasoning",
18
+ tokenParam: "max_completion_tokens",
19
+ allowsTemperature: false,
20
+ jsonResponseFormat: true,
21
+ minProbeCompletionTokens: 1024,
22
+ };
23
+ }
24
+ if (NEMOTRON_MODEL.test(model)) {
25
+ return {
26
+ family: "nvidia-nemotron",
27
+ tokenParam: "max_tokens",
28
+ allowsTemperature: true,
29
+ jsonResponseFormat: true,
30
+ // The kwargs switch is only honored (and only safe to send) on the NVIDIA
31
+ // endpoint; the system hint helps on any endpoint serving a Nemotron.
32
+ extraBody: baseUrl === NVIDIA_BASE_URL
33
+ ? { chat_template_kwargs: { enable_thinking: false } }
34
+ : undefined,
35
+ systemHint: "detailed thinking off",
36
+ minProbeCompletionTokens: 1024,
37
+ };
38
+ }
39
+ return {
40
+ family: "openai-compat",
41
+ tokenParam: "max_tokens",
42
+ allowsTemperature: true,
43
+ jsonResponseFormat: true,
44
+ minProbeCompletionTokens: 1024,
45
+ };
46
+ }
47
+ /** Build the chat-completions body for a route from its capability shape. */
48
+ export function buildChatBody(shape, args) {
49
+ const system = shape.systemHint
50
+ ? `${shape.systemHint}\n\n${args.system}`
51
+ : args.system;
52
+ return {
53
+ model: args.model,
54
+ ...(shape.allowsTemperature
55
+ ? { temperature: args.temperature ?? 0.2 }
56
+ : {}),
57
+ [shape.tokenParam]: args.maxTokens,
58
+ ...(shape.jsonResponseFormat
59
+ ? { response_format: { type: "json_object" } }
60
+ : {}),
61
+ ...(shape.extraBody ?? {}),
62
+ messages: [
63
+ { role: "system", content: system },
64
+ { role: "user", content: args.user },
65
+ ],
66
+ };
67
+ }
@@ -1,10 +1,28 @@
1
- import { AgentSpec } from "./types.js";
1
+ import { AgentSpec, ProviderName } from "./types.js";
2
2
  export interface DecideInput {
3
3
  system: string;
4
4
  user: string;
5
5
  maxTokens?: number;
6
6
  timeoutMs?: number;
7
7
  }
8
+ export interface DecideRouteAttempt {
9
+ provider: string;
10
+ model: string;
11
+ outcome: "success" | "failed" | "deferred";
12
+ failureClass?: "capacity" | "permanent" | "transient" | "malformed";
13
+ status?: number;
14
+ retryAfterMs?: number;
15
+ latencyMs: number;
16
+ error?: string;
17
+ }
18
+ export interface DecideRouteMeta {
19
+ policyVersion: string;
20
+ profile: "fast" | "strong" | "configured";
21
+ effectiveProvider?: string;
22
+ effectiveModel?: string;
23
+ reason: "configured" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
24
+ attempts: DecideRouteAttempt[];
25
+ }
8
26
  export type DecideResult = {
9
27
  ok: true;
10
28
  text: string;
@@ -12,9 +30,14 @@ export type DecideResult = {
12
30
  promptTokens: number;
13
31
  completionTokens: number;
14
32
  };
33
+ route?: DecideRouteMeta;
15
34
  } | {
16
35
  ok: false;
17
36
  error: string;
37
+ status?: number;
38
+ retryAfterMs?: number;
39
+ deferred?: boolean;
40
+ route?: DecideRouteMeta;
18
41
  };
19
42
  export interface Provider {
20
43
  label: string;
@@ -29,3 +52,8 @@ export interface ProviderEnv {
29
52
  MODEL_API_KEY?: string;
30
53
  }
31
54
  export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch): Provider;
55
+ export declare function providerForRoute(route: {
56
+ provider: ProviderName;
57
+ model: string;
58
+ baseUrl?: string | null;
59
+ }, apiKey: string, fetchFn?: typeof fetch): Provider;
@@ -2,9 +2,10 @@
2
2
  // never from an agent file. One call returns one chunk of text that must be a
3
3
  // single structured-JSON decision (parsed in decision.ts). No free-form tool
4
4
  // execution — the model only proposes; the runner disposes.
5
+ import { chatShapeFor, buildChatBody, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
5
6
  // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
6
7
  // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
7
- const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
8
+ const NVIDIA_BASE_URL = CAP_NVIDIA_BASE_URL;
8
9
  // Gemini exposes an OpenAI-compatible surface, so the `gemini` preset hard-wires
9
10
  // its hosted endpoint — an agent only needs `{ provider: gemini, name: "gemini-2.0-flash" }`
10
11
  // plus a GEMINI_API_KEY. The free tier (no credit card, generous Flash quota) makes
@@ -26,16 +27,9 @@ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai
26
27
  // (the recurring Leo/70B timeout). A real hang still aborts -> retried next cadence.
27
28
  // MUST stay below the scheduler's RUN_LOCK_SECONDS and HEARTBEAT_STALE_MS.
28
29
  const DEFAULT_TIMEOUT_MS = 300_000;
29
- // Reasoning models (NVIDIA Nemotron) DEFAULT to emitting a long <think> chain:
30
- // measured ~30-60s/call and a JSON-leak risk. The documented toggle is a
31
- // "detailed thinking off" line in the system prompt, which drops them to
32
- // instruct mode (measured ~3-4s, clean JSON). Apply it automatically for any
33
- // nemotron model so a per-cadence decision never blows the cadence.
34
- function applyReasoningToggle(model, system) {
35
- return /nemotron/i.test(model)
36
- ? `detailed thinking off\n\n${system}`
37
- : system;
38
- }
30
+ // Per-route request quirks (reasoning toggles, token param, temperature) live
31
+ // in the capability table providerCapabilities.ts is the single source; this
32
+ // module only assembles and sends.
39
33
  // fetch with a hard timeout via AbortController. A custom fetchFn (tests) that
40
34
  // ignores `signal` still works — the timer just never fires for it.
41
35
  async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
@@ -54,6 +48,23 @@ function callError(err, timeoutMs) {
54
48
  }
55
49
  return err instanceof Error ? err.message : String(err);
56
50
  }
51
+ // Parse a Retry-After header (delta-seconds or HTTP-date) into ms, capped at
52
+ // one hour — a provider asking for more is treated as "an hour, then re-probe".
53
+ const RETRY_AFTER_CAP_MS = 3_600_000;
54
+ function retryAfterMs(res) {
55
+ const raw = res.headers.get("retry-after");
56
+ if (!raw)
57
+ return undefined;
58
+ const secs = Number(raw);
59
+ if (Number.isFinite(secs) && secs >= 0) {
60
+ return Math.min(Math.round(secs * 1000), RETRY_AFTER_CAP_MS);
61
+ }
62
+ const at = Date.parse(raw);
63
+ if (!Number.isFinite(at))
64
+ return undefined;
65
+ const ms = at - Date.now();
66
+ return ms > 0 ? Math.min(ms, RETRY_AFTER_CAP_MS) : 0;
67
+ }
57
68
  function envKey(provider, env) {
58
69
  switch (provider) {
59
70
  case "anthropic":
@@ -141,6 +152,8 @@ class AnthropicProvider {
141
152
  // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
142
153
  // unbounded provider error page must not bloat the ledger row.
143
154
  error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
155
+ status: res.status,
156
+ retryAfterMs: retryAfterMs(res),
144
157
  };
145
158
  const json = (await res.json());
146
159
  const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
@@ -160,12 +173,14 @@ class AnthropicProvider {
160
173
  }
161
174
  }
162
175
  class OpenAiCompatProvider {
176
+ provider;
163
177
  model;
164
178
  apiKey;
165
179
  baseUrl;
166
180
  fetchFn;
167
181
  label;
168
- constructor(model, apiKey, baseUrl, fetchFn) {
182
+ constructor(provider, model, apiKey, baseUrl, fetchFn) {
183
+ this.provider = provider;
169
184
  this.model = model;
170
185
  this.apiKey = apiKey;
171
186
  this.baseUrl = baseUrl;
@@ -174,6 +189,7 @@ class OpenAiCompatProvider {
174
189
  }
175
190
  async decide(input) {
176
191
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
192
+ const shape = chatShapeFor(this.provider, this.model, this.baseUrl);
177
193
  try {
178
194
  const res = await fetchWithTimeout(this.fetchFn, `${this.baseUrl}/chat/completions`, {
179
195
  method: "POST",
@@ -181,19 +197,12 @@ class OpenAiCompatProvider {
181
197
  Authorization: `Bearer ${this.apiKey}`,
182
198
  "content-type": "application/json",
183
199
  },
184
- body: JSON.stringify({
200
+ body: JSON.stringify(buildChatBody(shape, {
185
201
  model: this.model,
186
- temperature: 0.2,
187
- max_tokens: input.maxTokens ?? 1024,
188
- response_format: { type: "json_object" },
189
- messages: [
190
- {
191
- role: "system",
192
- content: applyReasoningToggle(this.model, input.system),
193
- },
194
- { role: "user", content: input.user },
195
- ],
196
- }),
202
+ system: input.system,
203
+ user: input.user,
204
+ maxTokens: input.maxTokens ?? 1024,
205
+ })),
197
206
  }, timeoutMs);
198
207
  if (!res.ok)
199
208
  return {
@@ -201,6 +210,8 @@ class OpenAiCompatProvider {
201
210
  // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
202
211
  // unbounded provider error page must not bloat the ledger row.
203
212
  error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
213
+ status: res.status,
214
+ retryAfterMs: retryAfterMs(res),
204
215
  };
205
216
  const json = (await res.json());
206
217
  const text = json.choices?.[0]?.message?.content ?? "";
@@ -251,5 +262,19 @@ export function selectProvider(spec, env, fetchFn = fetch) {
251
262
  if (!resolvedBase) {
252
263
  throw new Error("openai-compatible provider needs model.baseUrl");
253
264
  }
254
- return new OpenAiCompatProvider(name, key, resolvedBase, fetchFn);
265
+ return new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
266
+ }
267
+ // Build a provider for an EXPLICIT route + raw key (no spec, no env) — the
268
+ // decision probe's entry point. Same classes as selectProvider, so a probe
269
+ // exercises byte-identical request shapes to a real cycle.
270
+ export function providerForRoute(route, apiKey, fetchFn = fetch) {
271
+ if (route.provider === "mechanical")
272
+ return new MechanicalProvider(route.model);
273
+ if (route.provider === "anthropic")
274
+ return new AnthropicProvider(route.model, apiKey, fetchFn);
275
+ const resolvedBase = baseUrlFor(route.provider, route.baseUrl ?? undefined);
276
+ if (!resolvedBase) {
277
+ throw new Error("openai-compatible route needs a baseUrl");
278
+ }
279
+ return new OpenAiCompatProvider(route.provider, route.model, apiKey, resolvedBase, fetchFn);
255
280
  }
@@ -14,7 +14,7 @@ export declare function mergeProseParts(parts: Array<{
14
14
  text: string;
15
15
  }>): string;
16
16
  export declare function resolveAgent(inputPath: string): ResolvedAgent;
17
- export declare const HOSTED_PROSE_MAX_CHARS = 8000;
17
+ export declare const HOSTED_PROSE_MAX_CHARS = 12000;
18
18
  /** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
19
19
  export declare const hostedProseBudget: (mergedProse: string) => {
20
20
  used: number;
@@ -592,7 +592,26 @@ export function resolveAgent(inputPath) {
592
592
  // tokens / 413s per cycle. Mirrored here (backend-v2
593
593
  // controllers/agentManage.ts sanitizeStrategyProse) so `validate --hosted`
594
594
  // can catch it before a user does.
595
- export const HOSTED_PROSE_MAX_CHARS = 8000;
595
+ //
596
+ // RAISED 8,000 -> 12,000 on 2026-08-21, from measurement rather than feel.
597
+ // 8,000 made the product's core promise impossible: forking a house template
598
+ // starts you at 7,967 (Olivia) / 7,931 (Carl) / 7,839 (Mia), so a user had
599
+ // 33 to 161 characters to write their own rules in. "Fork a template and make
600
+ // it yours" could not be done.
601
+ //
602
+ // The cap was justified by hosted inference cost. Measured over 8,060 LLM
603
+ // cycles in 24h on prod: average input is 9,038 tokens, of which the prose is
604
+ // only 12.6-40.2% (median ~25%) — the OBSERVATION is the other ~75%. Inputs
605
+ // already reached 17,065 tokens on Llama 3.1 8B and 16,437 on Nemotron 49B
606
+ // (measured on the since-retired NIM line; Nemotron 3 successors match), with
607
+ // ZERO rate-limit errors and estimated_cost_usd of 0.0000 (free NIM tier).
608
+ // +4,000 characters is ~+1,000 tokens/cycle (+11%), landing average input near
609
+ // 10,038 — still below what the fleet already handles at peak today.
610
+ //
611
+ // Self-host is deliberately NOT capped (runner.ts/prompt.ts enforce nothing):
612
+ // those agents run on the user's own model key, so their prompt size costs us
613
+ // nothing. This limit exists only where WE pay for the inference.
614
+ export const HOSTED_PROSE_MAX_CHARS = 12000;
596
615
  /** PURE — exported for tests. Mirrors the backend's trim-then-measure. */
597
616
  export const hostedProseBudget = (mergedProse) => {
598
617
  const used = mergedProse.replace(/\r\n/g, "\n").trim().length;
@@ -518,55 +518,100 @@ export async function runCycle(deps) {
518
518
  };
519
519
  }
520
520
  else {
521
- noteLlmCall(state, gate.codes, nowMs);
522
521
  const system = buildSystemPrompt(spec, mergedProse, {
523
522
  includeForecast: forecastEnabled,
524
523
  });
525
- const user = buildUserPrompt(observation, state.journal);
524
+ const user = buildUserPrompt(observation, state.journal, {
525
+ venues: spec.venues,
526
+ });
526
527
  const tokensInEst = Math.round((system.length + user.length) / 4);
527
528
  // Prompt-size + trigger visibility in the live terminal.
528
529
  log(`prompt ~${tokensInEst} tok ` +
529
530
  `(pm ${observation.pmMarkets.length}, trades ${observation.newClosedTrades.length}, watch ${observation.watch.length}, setups ${observation.setups.length}, triggers ${gate.codes.join("|") || "none"})`);
530
531
  const res = await provider.decide({ system, user });
532
+ const route = res.route;
533
+ const actualCallMade = route
534
+ ? route.attempts.some((attempt) => attempt.outcome !== "deferred")
535
+ : true;
536
+ // Capacity deferral made no provider call, so it must not consume the
537
+ // runner's debounce/LLM budget or delay recovery after capacity returns.
538
+ if (actualCallMade)
539
+ noteLlmCall(state, gate.codes, nowMs);
540
+ const effectiveProvider = actualCallMade
541
+ ? (route?.effectiveProvider ?? providerName)
542
+ : undefined;
531
543
  // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
532
- const tokensIn = res.ok
533
- ? (res.usage?.promptTokens ?? tokensInEst)
534
- : tokensInEst;
544
+ const tokensIn = !actualCallMade
545
+ ? 0
546
+ : res.ok
547
+ ? (res.usage?.promptTokens ?? tokensInEst)
548
+ : tokensInEst;
535
549
  const tokensOut = res.ok
536
550
  ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
537
551
  : 0;
538
- const estimatedCostUsd = estimateCostUsd(providerName, tokensIn, tokensOut);
552
+ const estimatedCostUsd = estimateCostUsd(effectiveProvider ?? providerName, tokensIn, tokensOut);
539
553
  meter = {
540
554
  triggerCodes: gate.codes,
541
- llmCallMade: true,
555
+ llmCallMade: actualCallMade,
542
556
  tokensIn,
543
557
  tokensOut,
544
558
  estimatedCostUsd,
559
+ effectiveProvider,
560
+ effectiveModel: actualCallMade
561
+ ? (route?.effectiveModel ?? spec.model?.name)
562
+ : undefined,
563
+ routeReason: route?.reason,
564
+ routeAttempts: route?.attempts,
545
565
  };
546
566
  if (!res.ok) {
567
+ if (res.deferred || !actualCallMade) {
568
+ saveState(stateFile, state);
569
+ log(`capacity deferred: ${res.error}`);
570
+ return {
571
+ decision: "skip",
572
+ skipReason: "provider capacity deferred",
573
+ planned: [],
574
+ modelFailed: false,
575
+ live,
576
+ ...meter,
577
+ decisionType: "gate_skip",
578
+ writeAttempted: 0,
579
+ writeAccepted: 0,
580
+ ...observationReceipt,
581
+ };
582
+ }
547
583
  state.consecutiveModelFailures += 1;
548
- // Permanent-failure classification: a 404/model_not_found is a
584
+ // Permanent-failure classification: a 404/410/model_not_found is a
549
585
  // 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.
586
+ // until something changes (live-measured 2026-08-26: NVIDIA EOL'd the
587
+ // whole Llama 3.x line and 35 agents died on the old disable path).
588
+ // Reliability slice 1: this class must NEVER disable the agent —
589
+ // provider failures are the PLATFORM's problem, not the user's. Three
590
+ // consecutive occurrences (rules out a routing fluke) now emit a
591
+ // providerHold: hosted, the scheduler folds holds into a fleet-wide
592
+ // (provider, model) circuit that skip-claims matching agents with
593
+ // backoff probes; self-host, the runner simply keeps retrying each
594
+ // cadence and recovers the moment the provider does. Disables remain
595
+ // for what deserves them: revoked credentials, drawdown, kill-switch,
596
+ // user action. Transient errors reset the permanent streak.
554
597
  if (isPermanentModelError(res.error)) {
555
598
  state.consecutivePermanentModelErrors =
556
599
  (state.consecutivePermanentModelErrors ?? 0) + 1;
557
600
  if (state.consecutivePermanentModelErrors >=
558
601
  PERMANENT_MODEL_ERROR_THRESHOLD) {
559
- state.disabled = true;
560
- state.disabledReason = `model_unavailable: ${res.error.slice(0, 160)}`;
602
+ const hold = {
603
+ provider: route?.effectiveProvider ?? spec.model?.provider ?? "unknown",
604
+ model: route?.effectiveModel ?? spec.model?.name ?? "unknown",
605
+ error: res.error.slice(0, 200),
606
+ };
561
607
  saveState(stateFile, state);
562
- log(`disabled: ${state.disabledReason}`);
608
+ log(`provider hold: ${hold.provider}/${hold.model} — ${res.error.slice(0, 120)}`);
563
609
  return {
564
610
  decision: "skip",
565
- skipReason: `model error: ${res.error}`,
611
+ skipReason: `provider hold: ${res.error}`,
566
612
  planned: [],
567
613
  modelFailed: true,
568
- disabled: true,
569
- disabledReason: state.disabledReason,
614
+ providerHold: hold,
570
615
  live,
571
616
  ...meter,
572
617
  decisionType: "model_error",
@@ -92,6 +92,12 @@ export function buildSpec(raw) {
92
92
  requireStopLoss: bool(risk.requireStopLoss, true),
93
93
  watchlist: strArr(risk.watchlist),
94
94
  blocklist: strArr(risk.blocklist),
95
+ // Only the two exact values pass; anything else stays undefined here and
96
+ // FAILS validation (skillValidator) — a typo like "shorts_only" must
97
+ // never silently mean "unrestricted".
98
+ direction: risk.direction === "long_only" || risk.direction === "short_only"
99
+ ? risk.direction
100
+ : undefined,
95
101
  },
96
102
  limits: {
97
103
  maxTradesPerDay: normalizeTradeCap(num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay)),
@@ -53,6 +53,13 @@ export function validateSkill(parsed, mode = "self-host") {
53
53
  add("skill_risk_sl", "risk.requireStopLoss must be true or false");
54
54
  if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
55
55
  add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
56
+ // Fail-closed on the side restriction: a typo ("shorts_only") must never
57
+ // silently mean "unrestricted" — that is exactly how a prose-only
58
+ // constraint failed live on 2026-08-24.
59
+ if (r.direction !== undefined &&
60
+ r.direction !== "long_only" &&
61
+ r.direction !== "short_only")
62
+ add("skill_risk_direction", 'risk.direction must be "long_only" or "short_only" (omit for both)');
56
63
  }
57
64
  // Model
58
65
  if (raw.model === undefined) {
@@ -97,7 +97,7 @@ const MODEL_FAILURE_FLOOR = 10;
97
97
  // consecutive occurrences rules out a one-off routing fluke without burning a
98
98
  // day. Auth failures get 10: a key rotation/propagation blip should not kill
99
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;
100
+ export const PERMANENT_MODEL_ERROR_RE = /model_not_found|model[_ ]decommissioned|has been decommissioned|\b404\b|\b410\b|reached (?:its )?end of life|no longer available|does not exist or you do not have access/i;
101
101
  export const PERMANENT_MODEL_ERROR_THRESHOLD = 3;
102
102
  export const AUTH_FAILURE_THRESHOLD = 10;
103
103
  export const isPermanentModelError = (error) => PERMANENT_MODEL_ERROR_RE.test(error);
@@ -47,6 +47,7 @@ const ALLOWED_KEYS = {
47
47
  "requireStopLoss",
48
48
  "watchlist",
49
49
  "blocklist",
50
+ "direction",
50
51
  ],
51
52
  sizing: null,
52
53
  limits: [
@@ -31,6 +31,7 @@ export interface RiskConfig {
31
31
  requireStopLoss: boolean;
32
32
  watchlist: string[];
33
33
  blocklist?: string[];
34
+ direction?: "long_only" | "short_only";
34
35
  }
35
36
  export interface LimitsConfig {
36
37
  maxTradesPerDay: number;
@@ -340,6 +341,16 @@ export interface CycleResult {
340
341
  modelFailed?: boolean;
341
342
  disabled?: boolean;
342
343
  disabledReason?: string;
344
+ /** Reliability slice 1 (2026-08-26): a PERMANENT provider/model failure
345
+ * (404/410/decommission class) no longer disables the agent. The runner
346
+ * reports the hold; the scheduler aggregates holds into a fleet-wide
347
+ * provider circuit (skip-claiming + backoff probes). User pauses, revoked
348
+ * credentials, drawdown and safety stops keep using `disabled`. */
349
+ providerHold?: {
350
+ provider: string;
351
+ model: string;
352
+ error: string;
353
+ };
343
354
  live: boolean;
344
355
  observationHash?: string;
345
356
  indicatorVersion?: string;
@@ -348,6 +359,19 @@ export interface CycleResult {
348
359
  tokensIn?: number;
349
360
  tokensOut?: number;
350
361
  estimatedCostUsd?: number;
362
+ effectiveProvider?: string;
363
+ effectiveModel?: string;
364
+ routeReason?: string;
365
+ routeAttempts?: Array<{
366
+ provider: string;
367
+ model: string;
368
+ outcome: "success" | "failed" | "deferred";
369
+ failureClass?: "capacity" | "permanent" | "transient" | "malformed";
370
+ status?: number;
371
+ retryAfterMs?: number;
372
+ latencyMs: number;
373
+ error?: string;
374
+ }>;
351
375
  decisionType?: "act" | "skip" | "gate_skip" | "model_error";
352
376
  writeAttempted?: number;
353
377
  writeAccepted?: number;
package/dist/http.js CHANGED
@@ -79,6 +79,27 @@ async function main() {
79
79
  },
80
80
  });
81
81
  });
82
+ // robots.txt for THIS host. robots.txt is per-HOST, so www.coinrithm.com's
83
+ // file never governed mcp.coinrithm.com — a separate origin that had no
84
+ // route of its own. The origin 404'd and Cloudflare answered with its
85
+ // managed content-signals boilerplate: 1,248 bytes of comments carrying ZERO
86
+ // User-agent/Disallow/Allow lines, which a crawler reads as "crawl
87
+ // everything". That is the identical failure that cost api.coinrithm.com
88
+ // 15.4% of the site's 90-day crawl budget (4,468 of 29,100 GSC requests)
89
+ // before it was closed on 2026-08-20.
90
+ //
91
+ // Nothing here is indexable: GET / is a JSON service descriptor, GET /mcp is
92
+ // a 405, and the real surface is POST-only streamable HTTP. The human-facing
93
+ // documentation crawlers should index lives on www.coinrithm.com
94
+ // (/en/agentic-trading, /en/prediction-markets/api), which links here.
95
+ //
96
+ // SAFE FOR MCP CLIENTS AND REGISTRIES: robots.txt is advisory to CRAWLERS
97
+ // only. MCP clients, Smithery and the MCP registry POST /mcp or GET /healthz
98
+ // directly and never consult robots.txt, so this cannot gate discovery,
99
+ // initialization or tool listing. Do not "fix" a registry problem here.
100
+ app.get("/robots.txt", (_req, res) => {
101
+ res.type("text/plain").send("User-agent: *\nDisallow: /\n");
102
+ });
82
103
  app.get("/mcp", (_req, res) => {
83
104
  res.status(405).json({
84
105
  error: "method_not_allowed",
package/dist/tools.js CHANGED
@@ -971,16 +971,16 @@ export function registerTools(server, client) {
971
971
  }, async ({ runId, agentTrace }, extra) => present(await client.exportLedger({ runId }, requestKey(extra), agentTrace)));
972
972
  server.registerTool("get_arena_leaderboard", {
973
973
  title: "Get Agent Arena leaderboard",
974
- description: "The public Agent Arena: opted-in agents ranked by total realized PnL " +
975
- "(mUSD) across spot, futures, and prediction markets, with per-venue " +
976
- "breakdown and win rate. Only agents with at least minDecidedTrades " +
977
- "decided (win+loss) trades rank (currently 3 echoed in the " +
978
- "response); demo/house agents seed the board until live agents " +
979
- "qualify. Rows also carry a 44-day sparkline, badges, rankDelta, " +
980
- "biggestWinMusd, and the self-reported model label. Pass " +
981
- "window='7d'|'30d' for the weekly/monthly board re-ranked by PnL " +
982
- "realized inside the window (badges/biggestWin and the min-decided " +
983
- "gate stay all-time). Use it to see the field and where you stand — pair " +
974
+ description: "The public Agent Arena across spot, futures, and prediction markets. " +
975
+ "The response publishes the arena-ranking-v1 contract: five decided " +
976
+ "trades qualify an agent for normal ordering; positive realized PnL is " +
977
+ "weighted by the 95% Wilson win-confidence lower bound; non-positive " +
978
+ "PnL is used directly. Agents below five remain listed after qualified " +
979
+ "agents; fewer than 20 decided trades is a separate small-sample " +
980
+ "warning. Rows carry per-venue results, a 90-day sparkline, badges, " +
981
+ "rankDelta, biggestWinMusd, and a self-reported model label. Pass " +
982
+ "window='today'|'24h'|'7d'|'30d'|'3m'|'all'. Use it to see the field " +
983
+ "and where you stand — pair " +
984
984
  "with get_performance (your own scorecard) and get_arena_agent (drill " +
985
985
  "into one handle). Public data: agent names + performance only. " +
986
986
  PAPER_NOTE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinrithm/mcp-trading",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "mcpName": "io.github.CoinRithm/mcp-trading",
5
5
  "description": "CoinRithm paper-trading toolkit: an MCP server (coinrithm-mcp) AND a self-host agent runner (coinrithm-agent) for spot, futures, and prediction markets with a user-minted API key.",
6
6
  "type": "module",