@coinrithm/mcp-trading 0.7.15 → 0.7.16

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +127 -8
  2. package/README.md +18 -3
  3. package/dist/agent/bench/bench.d.ts +59 -0
  4. package/dist/agent/bench/bench.js +319 -0
  5. package/dist/agent/bench/cassette.d.ts +97 -0
  6. package/dist/agent/bench/cassette.js +181 -0
  7. package/dist/agent/bench/costs.d.ts +28 -0
  8. package/dist/agent/bench/costs.js +60 -0
  9. package/dist/agent/bench/labelBuilder.d.ts +123 -0
  10. package/dist/agent/bench/labelBuilder.js +282 -0
  11. package/dist/agent/bench/labels.d.ts +75 -0
  12. package/dist/agent/bench/labels.js +259 -0
  13. package/dist/agent/bench/recordingClient.d.ts +51 -0
  14. package/dist/agent/bench/recordingClient.js +193 -0
  15. package/dist/agent/bench/replayClient.d.ts +41 -0
  16. package/dist/agent/bench/replayClient.js +262 -0
  17. package/dist/agent/bench/report.d.ts +115 -0
  18. package/dist/agent/bench/report.js +435 -0
  19. package/dist/agent/cli.d.ts +25 -0
  20. package/dist/agent/cli.js +300 -2
  21. package/dist/agent/client.d.ts +1 -0
  22. package/dist/agent/client.js +6 -0
  23. package/dist/agent/configurationWarnings.js +11 -0
  24. package/dist/agent/decision.d.ts +3 -0
  25. package/dist/agent/decision.js +85 -3
  26. package/dist/agent/decisionValidator.d.ts +30 -0
  27. package/dist/agent/decisionValidator.js +147 -2
  28. package/dist/agent/engine.d.ts +3 -3
  29. package/dist/agent/engine.js +3 -3
  30. package/dist/agent/observe.d.ts +27 -2
  31. package/dist/agent/observe.js +529 -89
  32. package/dist/agent/prompt.d.ts +4 -1
  33. package/dist/agent/prompt.js +98 -7
  34. package/dist/agent/promptTables.d.ts +1 -0
  35. package/dist/agent/promptTables.js +33 -0
  36. package/dist/agent/providerCapabilities.d.ts +6 -0
  37. package/dist/agent/providerCapabilities.js +23 -0
  38. package/dist/agent/providers.d.ts +25 -7
  39. package/dist/agent/providers.js +188 -48
  40. package/dist/agent/resolve.js +3 -0
  41. package/dist/agent/runner.d.ts +8 -0
  42. package/dist/agent/runner.js +59 -15
  43. package/dist/agent/setups.d.ts +2 -1
  44. package/dist/agent/setups.js +17 -16
  45. package/dist/agent/signals.d.ts +33 -0
  46. package/dist/agent/signals.js +89 -0
  47. package/dist/agent/skill.js +37 -0
  48. package/dist/agent/skillValidator.js +33 -0
  49. package/dist/agent/strictLint.js +29 -0
  50. package/dist/agent/types.d.ts +149 -12
  51. package/dist/agent/universe.d.ts +78 -0
  52. package/dist/agent/universe.js +215 -0
  53. package/package.json +3 -2
@@ -1,4 +1,4 @@
1
- import { AgentSpec, Observation, PmResolution, RunState } from "./types.js";
1
+ import { AgentSpec, Observation, PmResolution, RunState, type ObjectivePrimary } from "./types.js";
2
2
  import type { DecisionActionExclusion } from "./providerCapabilities.js";
3
3
  export interface DailyRiskBudget {
4
4
  version: "coinrithm.daily-risk-budget.v1";
@@ -19,6 +19,8 @@ export interface FuturesCapacity {
19
19
  }
20
20
  export declare function buildFuturesCapacity(spec: AgentSpec, obs: Pick<Observation, "openPositions">): FuturesCapacity;
21
21
  export declare function formatPmResolutions(resolutions: PmResolution[]): string[];
22
+ export declare const OBJECTIVE_GUIDANCE: Record<ObjectivePrimary, string>;
23
+ export declare function objectiveLine(spec: Pick<AgentSpec, "objective">): string | null;
22
24
  export declare function buildSystemPrompt(spec: AgentSpec, mergedProse: string, opts?: {
23
25
  includeForecast?: boolean;
24
26
  excludeActionTypes?: readonly DecisionActionExclusion[];
@@ -31,5 +33,6 @@ export declare function buildUserPrompt(obs: Observation, journal?: Array<{
31
33
  dailyRiskBudget?: DailyRiskBudget;
32
34
  capitalSizing?: AgentSpec["capitalSizing"];
33
35
  futuresCapacity?: FuturesCapacity;
36
+ compactTables?: boolean;
34
37
  excludeActionTypes?: readonly DecisionActionExclusion[];
35
38
  }): string;
@@ -4,8 +4,11 @@
4
4
  // prefix stable and avoiding balances or counts in the system prompt.
5
5
  // The model only PROPOSES — the runner re-checks every action against the caps,
6
6
  // so the prompt states the caps but never relies on the model to honor them.
7
+ import { barLabel, indicatorRangeOf, signalThresholdsOf } from "./signals.js";
8
+ import { describeUniverse, scansUniverse } from "./universe.js";
7
9
  import { pmQualityOf, pmDecisionSupportOf } from "./pmContext.js";
8
10
  import { usesCapitalSizing } from "./capitalSizing.js";
11
+ import { serializePromptObservation } from "./promptTables.js";
9
12
  // The runner has already called rollDay. Use the same counter as validation,
10
13
  // including conservative legacy-state migration; never infer it from writes,
11
14
  // model calls, or the number of positions that remain open.
@@ -69,6 +72,23 @@ export function formatPmResolutions(resolutions) {
69
72
  `Resolved since last cycle: ${items.join("; ")}.`,
70
73
  ];
71
74
  }
75
+ // The agent's declared objective (spec.objective.primary), stated to the
76
+ // model as how to weigh trade-offs (Studio audit, root 56825: the Studio
77
+ // promised the objective "shapes how it weighs risk vs return", but nothing
78
+ // read it). Guidance only: the hard caps below still decide what executes.
79
+ export const OBJECTIVE_GUIDANCE = {
80
+ realized_pnl: "realized PnL after fees. Prefer setups with clear reward over risk; closing winners and cutting losers counts as much as entries.",
81
+ risk_adjusted: "return per unit of risk. Prefer fewer, cleaner setups with a meaningful stop; when the risk is unclear, skip.",
82
+ drawdown_control: "preserving capital and keeping drawdowns small, within your declared strategy and the existing risk limits.",
83
+ calibration: "honest, evidence-based probabilities. Report what the evidence supports and keep your uncertainty visible; a forecast is valid without a trade, and a trade needs adequate edge, otherwise skip it.",
84
+ };
85
+ export function objectiveLine(spec) {
86
+ const primary = spec.objective?.primary;
87
+ return primary &&
88
+ Object.prototype.hasOwnProperty.call(OBJECTIVE_GUIDANCE, primary)
89
+ ? `Optimise for ${OBJECTIVE_GUIDANCE[primary]} This is how to weigh trade-offs within your strategy; it never overrides a hard cap.`
90
+ : null;
91
+ }
72
92
  export function buildSystemPrompt(spec, mergedProse,
73
93
  // includeForecast (default OFF here; the runner passes the house-agent flag):
74
94
  // when true, pm_open asks for a market-aware forecastProbability (1..99):
@@ -128,6 +148,9 @@ opts = {}) {
128
148
  "## Your strategy (your borders)",
129
149
  mergedProse.trim() || "(no strategy prose provided)",
130
150
  "",
151
+ ...(objectiveLine(spec)
152
+ ? ["## Your objective", objectiveLine(spec), ""]
153
+ : []),
131
154
  "## Hard caps the runner enforces (do not exceed; proposing over a cap wastes the cycle)",
132
155
  "- When supplied, the user prompt's dailyRiskBudget is the remaining UTC-day entry/add allowance. It outranks setup/entry pressure: exhaustion is a legitimate skip for new risk, never a reason to skip otherwise-valid closes or protection. All other caps still apply, even when this daily count is unlimited.",
133
156
  `- venues you may act in: ${v.join(", ")}`,
@@ -162,7 +185,7 @@ opts = {}) {
162
185
  // sections telling one story.
163
186
  ...(hasCoinVenue
164
187
  ? [
165
- spec.capabilities.includes("universe_scan")
188
+ scansUniverse(spec)
166
189
  ? `- tradable symbols (${coinVenueLabel}): your watchlist (${r.watchlist.join(", ")}) PLUS this cycle's watch entries marked \`discovered: true\` — nothing outside those`
167
190
  : `- watchlist (${coinVenueLabel} use ONLY these): ${r.watchlist.join(", ")}`,
168
191
  ]
@@ -184,6 +207,31 @@ opts = {}) {
184
207
  `- PM ENTRY FLOOR: pm_open on an outcome whose market probability is below ${r.pmMinEntryProbabilityPct} points is REJECTED by the runner (the chosen outcome's own price; fees are not counted). Do not propose cheaper longshots; look for edge on outcomes priced at or above the floor.`,
185
208
  ]
186
209
  : []),
210
+ ...(hasPm && typeof r.pmMinEdgeGapPct === "number"
211
+ ? [
212
+ `- PM EDGE RULE: every pm_open MUST carry forecastProbability, and it must beat the fee-inclusive cost by ${r.pmMinEdgeGapPct}% of the room left to 100 (cost 50 needs ${(50 + (r.pmMinEdgeGapPct / 100) * 50).toFixed(1)}, cost 70 needs ${(70 + (r.pmMinEdgeGapPct / 100) * 30).toFixed(1)}). The runner REJECTS an open without a forecast or under this bar.`,
213
+ ]
214
+ : []),
215
+ ...(hasPm && typeof r.pmMaxEdgePoints === "number"
216
+ ? [
217
+ `- PM OVERCONFIDENCE CAP: every pm_open MUST carry forecastProbability, and it may beat the fee-inclusive cost by at most ${r.pmMaxEdgePoints} points. Base the forecast on current evidence. Do not alter a forecast to pass this cap: skip the trade if your evidence-based forecast falls outside the permitted range. Opens over the cap are REJECTED.`,
218
+ ]
219
+ : []),
220
+ ...(hasPm && typeof r.pmMaxOpenPerEvent === "number"
221
+ ? [
222
+ `- PM PER-EVENT CAP: at most ${r.pmMaxOpenPerEvent} open bet(s) per event (same market slug, counting bets you already hold). Extra opens are REJECTED.`,
223
+ ]
224
+ : []),
225
+ ...(hasPm && typeof r.pmMaxOpenPerCoinHorizon === "number"
226
+ ? [
227
+ `- PM COIN-HORIZON CAP: at most ${r.pmMaxOpenPerCoinHorizon} open bet(s) on the same coin whose events end within 24 h of each other, across DIFFERENT events (counting bets you already hold; uses each market's linked coins and end date). Extra opens are REJECTED.`,
228
+ ]
229
+ : []),
230
+ ...(hasPm && typeof r.pmMinMinutesToClose === "number"
231
+ ? [
232
+ `- PM CLOSE CUTOFF: a market whose \`end\` is less than ${r.pmMinMinutesToClose} minutes away when the runner validates your action is REJECTED; the price already knows.`,
233
+ ]
234
+ : []),
187
235
  ...(includeForecast
188
236
  ? [
189
237
  "- FORECAST RULE (pm_open forecastProbability): the current market probability is already included in this request, so this is a market-aware estimate, not a blinded forecast. Form your own evidence-based probability that the outcome you are backing actually WINS, using the question, its resolution criteria, deadline, and available evidence. Put that number (1-99, whole or one decimal) in `forecastProbability`. This is graded against reality as your PUBLIC calibration record, so it must reflect your judgement: do NOT mechanically copy or round observation.pmMarkets `prob` to produce a forecast. 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 evidence-based view, OMIT the field rather than parroting the market (an absent forecast is better than a fake one, and it never blocks the bet). A forecast you DO give is enforced: if it is not above what the outcome currently costs, the open is rejected, because buying something you price below the market is a losing trade by your own numbers.",
@@ -192,20 +240,37 @@ opts = {}) {
192
240
  `- abstention.minConfidence ${spec.abstention.minConfidence}: opens below this are rejected, so act with genuine conviction — but routine caution is no reason to sit out a clear setup`,
193
241
  ...(hasCoinVenue
194
242
  ? [
243
+ "- Open interest (watch[].openInterest) is single-side perpetual exposure in USD across the named venues, not the entire market. Each contract has both a long and a short: OI with price alone cannot establish who opened, closed, or was liquidated. USD OI can also move as price changes without contract counts changing. Treat long/short-covering interpretations as hypotheses requiring other evidence, never as a standalone trade signal. Read each change WITH its change1hVenues/change24hVenues: only matching venue-contracts contribute, and that set may differ from the total's venues. Null changes are unknown; a reported zero total is valid. Ignore stale or missing readings and check asOf against the current observation clock.",
244
+ "- Positioning (watch[].positioning, Binance, top coins by open interest only) counts ACCOUNTS (longShortAccountRatio, longAccountPct) or top-trader POSITIONS (topTraderPositionRatio); takerBuySellRatio is taker buy over sell volume for the last CLOSED 15-minute period. Each value has its own asOf. They describe who is positioned, not where price goes: a crowded side is a hypothesis, never a standalone signal. Ignore stale values.",
245
+ "- Funding venues (watch[].fundingByVenue): rateFraction is a fraction per intervalHours, not a percent. hourlyEquivalentFraction = rateFraction / intervalHours is simple normalization, not realized performance, APR or forecast return; an unknown interval stays null. Venues are collected independently, not at the same time. Read each fetchedAt/ageSeconds/stale; sourceAt=null means provider observation time and delay are unknown. context_only does not replace the paper settlement reference or entry gate. Ignore stale or missing values and do not treat a funding difference alone as a trade signal.",
246
+ "- Depth: watch[].depth is ONE venue's visible order book (Hyperliquid, at most 20 price levels per side), NOT total market liquidity and NOT an executable fill. bid/ask each give usd (resting USD in the visible levels), reachPct (how far from the mid those levels reach, often well under 1%), levels and complete. complete=false means liquidity beyond reachPct was not returned and remains unknown, never zero; null is unknown. Resting orders can be cancelled at any time; treat a bid/ask imbalance as a weak hint, never a standalone signal. Ignore stale values.",
247
+ "- DeFi: watch[].chainTvl is the value locked on the chain associated with that asset by DefiLlama (an association, not proof it is the chain's native token), NOT the coin's market value; change1dPct/change7dPct compare the provider's dated daily points (dayAt). The current TVL has no observation time (sourceObservedAt null): publishedAt is when the provider published the response and fetchedAt is when we collected it; neither dates the TVL itself. observation.stablecoinSupply is total stablecoin supply market-wide with 1d/7d changes. Both are slow structural context, never a short-term trade trigger; a stale value or a null change is unknown.",
248
+ "- Macro (observation.macro): Hyperliquid xyz perpetuals that track the S&P 500, Nasdaq-100, Nikkei, KOSPI, gold, silver, oil, gas, copper, EUR/USD, GBP/USD, USD/JPY and TLT around the clock: PROXIES, not exchange quotes, each with its own asOf. Use them as risk-on/risk-off context only; a stale quote is unknown.",
249
+ "- Liquidations (watch[].liquidations, OKX USDT swaps only): long/short liquidated USDT notional over the last 1h / 24h, read WITH capturedPct, which is a LOWER BOUND of OUR capture uptime for the window (at least that share was captured), not exchange completeness. Low capturedPct means unknown, never 'no liquidations'. Other venues are not included.",
195
250
  "- Community sentiment is a dated sample: read sentimentBullishPct WITH sentimentTotalVotes and sentimentDayUtc. A tiny or old cohort is weak evidence, not current market consensus. sentimentUpdatedAt is the cohort's write time. Missing counts/dates are unknown; price freshness does not date sentiment. marketMood.fetchedAt is Fear & Greed collection time, not its provider observation time. Compare each clock with observation.asOf; never invent currentness from a missing date.",
196
251
  ]
197
252
  : []),
198
253
  ...(spec.capabilities.includes("indicators")
199
254
  ? [
200
255
  "",
201
- "## Signals — each watch entry may carry `indicators` (nominal five-minute candles)",
202
- "- `indicatorContext` reports accepted candle counts, source `asOf` and intervalStatus (regular/irregular/unknown); compare its asOf with observation.asOf for age. /market freshness is separate. nominalIntervalSeconds=300 does not prove fresh, continuous candles. Missing timestamps are unknown; stale, future-dated or irregular candles do not establish a current five-minute signal. `recent15` describes only recent spacing; Wilder atr14 also retains earlier history, so recent regularity does not erase older gaps.",
203
- "- rsi14: momentum (>70 overbought, <30 oversold); ema20 & ema50: trend; atr14: volatility (size stops off it); bollinger {upper,mid,lower}; recent20 {high,low}: breakout levels.",
256
+ `## Signals — each watch entry may carry \`indicators\` (nominal ${barLabel(indicatorRangeOf(spec))} candles)`,
257
+ "- `indicatorContext` reports accepted candle counts, source `asOf` and intervalStatus (regular/irregular/unknown); compare its asOf with observation.asOf for age. /market freshness is separate. nominalIntervalSeconds does not prove fresh, continuous candles. Missing timestamps are unknown; stale, future-dated or irregular candles do not establish a current signal. `recent15` describes only recent spacing; Wilder atr14 also retains earlier history, so recent regularity does not erase older gaps.",
258
+ `- rsi14: momentum (this agent flags overbought at ${signalThresholdsOf(spec).rsiOverbought} and oversold at ${signalThresholdsOf(spec).rsiOversold}); ema20 & ema50: trend; atr14: volatility (size stops off it); bollinger {upper,mid,lower}; recent20 {high,low}: breakout levels.`,
204
259
  "- boolean reads: aboveEma20, ema20AboveEma50 (uptrend when both true), brokeRecentHigh (breakout), brokeRecentLow (breakdown).",
205
260
  "- a null field = not enough data; ignore it. These INFORM your decision; they never widen a cap.",
206
261
  ]
207
262
  : []),
208
- ...(spec.capabilities.includes("universe_scan")
263
+ ...(spec.universe
264
+ ? [
265
+ "",
266
+ "## Your market (declared boundaries) — candidates beyond your watchlist",
267
+ `Each cycle the runner scans the market inside YOUR boundaries: ${describeUniverse(spec.universe)}. Watch entries with \`discovered: true\` are the top rows of that scan, resolved with the same price/sentiment (and indicators) data as your watchlist. observation.universeMovers lists further rows as symbol + 24h change only (context — you cannot trade those directly this cycle). Nothing outside these boundaries is shown to you or tradable.`,
268
+ "- Treat a discovered candidate like any other symbol: analyze it for catalysts, exhaustion and reversal BEFORE acting. A big move is as often a top as a beginning — chasing candles blind is how discovery loses money.",
269
+ "- All your normal risk rules apply unchanged: caps, stops, blocklist, confidence floor. Discovery widens what you can SEE, never what you may risk.",
270
+ "- Your boundaries also bind your watchlist: a watch entry with `withinBoundaries: false` (or no withinBoundaries and not discovered) is context only. You may close or sell an existing position in it, but a new entry is REJECTED (outside_universe).",
271
+ ]
272
+ : []),
273
+ ...(!spec.universe && spec.capabilities.includes("universe_scan")
209
274
  ? [
210
275
  "",
211
276
  "## Universe scan (discovered movers) — candidates beyond your watchlist",
@@ -391,6 +456,31 @@ export function buildUserPrompt(obs, journal, opts = {}) {
391
456
  ]
392
457
  : []));
393
458
  }
459
+ // Futures entry rules the runner enforces, stated with THIS agent's numbers.
460
+ // The weekly house report (2026-09-30..10-07) found the two largest refusal
461
+ // classes were opens below the fee-inclusive reward:risk floor
462
+ // (capital_quote_reward_risk_too_low) and SL/TP on an add
463
+ // (add_cannot_carry_sltp). Wording only: the validators are unchanged.
464
+ if (hasFutures && !futuresOpenWithheld) {
465
+ const held = Array.from(new Set(obs.openPositions
466
+ .filter((p) => p.venue === "futures" &&
467
+ (p.status ?? "open") === "open" &&
468
+ typeof p.symbol === "string" &&
469
+ p.symbol.length > 0)
470
+ .map((p) => p.symbol.toUpperCase())));
471
+ const minRewardRisk = opts.capitalSizing?.minRewardRisk;
472
+ if (typeof minRewardRisk === "number" &&
473
+ Number.isFinite(minRewardRisk) &&
474
+ minRewardRisk >= 1) {
475
+ lines.push(`REWARD:RISK FLOOR ${minRewardRisk} (your capitalSizing.minRewardRisk, enforced with fees; an open below it is rejected as capital_quote_reward_risk_too_low): the take-profit's distance from entry, minus the entry and exit fees, must be at least ${minRewardRisk}x the stop's distance from entry plus those fees. A target exactly ${minRewardRisk}x the stop distance FAILS once fees are counted, so leave room. If the setup cannot offer such a target, do not open it.`);
476
+ }
477
+ if (held.length > 0) {
478
+ const list = held.join(", ");
479
+ lines.push(opts.capitalSizing
480
+ ? `You already hold futures on ${list}. Under your capital policy you cannot ADD to them: every futures_open needs its own stopLossPrice and takeProfitPrice for sizing, and the server refuses SL/TP on an add (add_cannot_carry_sltp). Propose NO futures_open on ${list}; manage them with futures_set_sltp or futures_close on their positionId.`
481
+ : `You already hold futures on ${list}. A futures_open on any of them is an ADD and must carry NO stopLossPrice or takeProfitPrice (the whole open is rejected as add_cannot_carry_sltp); adjust protection with futures_set_sltp on the positionId instead.`);
482
+ }
483
+ }
394
484
  // Flat-state steer: when the agent holds NOTHING, weaker models (Llama 3.1 8B)
395
485
  // still emit futures_close / futures_set_sltp / spot_cancel with a hallucinated
396
486
  // positionId/orderId — which fails the whole cycle's strict parse (one bad id
@@ -442,7 +532,7 @@ export function buildUserPrompt(obs, journal, opts = {}) {
442
532
  lines.push("", "```json",
443
533
  // Compact (no pretty-print indentation — ~40% fewer tokens, still valid JSON)
444
534
  // and the trade ledger is capped so a busy shared book can't bloat the prompt.
445
- JSON.stringify({
535
+ serializePromptObservation({
446
536
  asOf: obs.asOf,
447
537
  ...(opts.dailyRiskBudget
448
538
  ? { dailyRiskBudget: opts.dailyRiskBudget }
@@ -505,8 +595,9 @@ export function buildUserPrompt(obs, journal, opts = {}) {
505
595
  universeMovers: obs.universeMovers,
506
596
  whaleContext: obs.whaleContext,
507
597
  marketMood: obs.marketMood,
598
+ ...(obs.macro ? { macro: obs.macro } : {}),
508
599
  newClosedTrades: obs.newClosedTrades.slice(0, 20),
509
600
  polledBeforeWrite: obs.polledBeforeWrite,
510
- }), "```", "", "Return ONLY the JSON decision object.");
601
+ }, opts.compactTables), "```", "", "Return ONLY the JSON decision object.");
511
602
  return lines.join("\n");
512
603
  }
@@ -0,0 +1 @@
1
+ export declare function serializePromptObservation(value: unknown, compact?: boolean): string;
@@ -0,0 +1,33 @@
1
+ const row = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
2
+ // Keep market refs and per-outcome settlement rules in their proven object
3
+ // representation. A provider probe mis-associated an optional rule with a ref.
4
+ const TABLE_FIELDS = ["watch", "pmPositions", "newClosedTrades"];
5
+ const FORMAT = "Tabular lists use columns and rows: each row's values map to the columns in order. Every row remains a separate item; nested objects keep their field names. References such as watch[].priceUsd refer to the reconstructed row. Only lists with identical fields use tables. Market refs and settlement rules remain objects. Return the normal decision JSON with actions as an array of objects.";
6
+ export function serializePromptObservation(value, compact = false) {
7
+ const original = JSON.stringify(value);
8
+ if (!compact)
9
+ return original;
10
+ const data = JSON.parse(original);
11
+ let changed = false;
12
+ for (const field of TABLE_FIELDS) {
13
+ const list = data[field];
14
+ if (!Array.isArray(list) || list.length < 3 || !list.every(row))
15
+ continue;
16
+ const columns = Object.keys(list[0]).filter((k) => list.every((r) => Object.hasOwn(r, k)));
17
+ if (columns.length < 3 ||
18
+ list.some((r) => Object.keys(r).length !== columns.length))
19
+ continue;
20
+ const table = {
21
+ columns,
22
+ rows: list.map((r) => columns.map((k) => r[k])),
23
+ };
24
+ if (JSON.stringify(list).length - JSON.stringify(table).length < 512)
25
+ continue;
26
+ data[field] = table;
27
+ changed = true;
28
+ }
29
+ if (!changed)
30
+ return original;
31
+ const result = JSON.stringify({ tableFormat: FORMAT, ...data });
32
+ return result.length < original.length ? result : original;
33
+ }
@@ -1,6 +1,11 @@
1
1
  import { ProviderName } from "./types.js";
2
2
  export declare const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
3
3
  export declare const DECISION_TOOL_NAME = "submit_trading_decision";
4
+ export declare function canRetrySuperToolOutput(route: {
5
+ provider: string;
6
+ model: string;
7
+ baseUrl?: string | null;
8
+ }, responseSource: string | undefined, actionsStringDiagnostic: string | undefined): boolean;
4
9
  export interface ChatShape {
5
10
  family: "openai-reasoning" | "nvidia-nemotron" | "anthropic" | "openai-compat";
6
11
  tokenParam: "max_tokens" | "max_completion_tokens";
@@ -13,6 +18,7 @@ export interface ChatShape {
13
18
  minProbeCompletionTokens: number;
14
19
  }
15
20
  export declare function chatShapeFor(provider: ProviderName, model: string, baseUrl?: string): ChatShape;
21
+ export declare function withJsonContentTransport(shape: ChatShape): ChatShape;
16
22
  export type DecisionActionExclusion = "futures_open";
17
23
  export declare function restrictDecisionSchema(schema: Record<string, unknown>, exclude: readonly DecisionActionExclusion[] | undefined): Record<string, unknown>;
18
24
  /** Build the chat-completions body for a route from its capability shape. */
@@ -1,6 +1,16 @@
1
1
  import { DECISION_JSON_SCHEMA } from "./decision.js";
2
2
  export const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
3
3
  export const DECISION_TOOL_NAME = "submit_trading_decision";
4
+ // A retry changes transport only after the strict parser has rejected a known
5
+ // Super tool-argument encoding failure. Valid responses and other routes keep
6
+ // their original request shape. A custom endpoint is deliberately excluded.
7
+ export function canRetrySuperToolOutput(route, responseSource, actionsStringDiagnostic) {
8
+ return (route.provider === "nvidia" &&
9
+ route.model === "nvidia/nemotron-3-super-120b-a12b" &&
10
+ (route.baseUrl == null || route.baseUrl === NVIDIA_BASE_URL) &&
11
+ responseSource === "tool_call" &&
12
+ actionsStringDiagnostic !== undefined);
13
+ }
4
14
  // gpt-5*, o1/o3/o4* — the OpenAI reasoning-API family, wherever it is served.
5
15
  const OPENAI_REASONING_MODEL = /^(gpt-5|o[0-9])/i;
6
16
  const NEMOTRON_MODEL = /nemotron/i;
@@ -54,6 +64,19 @@ export function chatShapeFor(provider, model, baseUrl) {
54
64
  minProbeCompletionTokens: 1024,
55
65
  };
56
66
  }
67
+ // Opt-in alternative for a hosted Nemotron route: plain JSON content
68
+ // (`response_format: json_object`) instead of the forced decision tool call.
69
+ // Hosted Super has returned the forced tool call with `actions` as a string
70
+ // (519 of 533 malformed attempts in a 24h sample, 2026-10-05). In an 8-call
71
+ // synthetic smoke pilot the tool-call arms failed 0/4 with the same coarse
72
+ // string shape and the JSON-content arm parsed 2/2; that is not a rate
73
+ // measurement and the exact serialization cause is not established. Only the
74
+ // request transport changes: parseDecision still validates the full contract.
75
+ export function withJsonContentTransport(shape) {
76
+ if (shape.family !== "nvidia-nemotron")
77
+ return shape;
78
+ return { ...shape, jsonSchema: undefined, jsonSchemaTransport: undefined };
79
+ }
57
80
  // Transport-only restriction: drop the excluded variants from the action oneOf
58
81
  // a route decodes against. parseDecision keeps validating the FULL contract, so
59
82
  // a route that does not enforce a schema can still propose the action and the
@@ -1,8 +1,20 @@
1
1
  import { AgentSpec, ProviderName } from "./types.js";
2
+ import { type ActionsStringDiagnostic } from "./decision.js";
2
3
  import { type DecisionActionExclusion } from "./providerCapabilities.js";
4
+ export interface ProviderUsage {
5
+ promptTokens: number;
6
+ completionTokens: number;
7
+ cacheReadTokens?: number;
8
+ cacheWriteTokens?: number;
9
+ cacheWrite5mTokens?: number;
10
+ cacheWrite1hTokens?: number;
11
+ }
12
+ export declare function anthropicUsage(raw: unknown): ProviderUsage | undefined;
3
13
  export interface DecideInput {
4
14
  system: string;
5
15
  user: string;
16
+ /** Optional equivalent presentation for explicitly verified hosted routes. */
17
+ compactUser?: string;
6
18
  maxTokens?: number;
7
19
  timeoutMs?: number;
8
20
  excludeActionTypes?: readonly DecisionActionExclusion[];
@@ -16,6 +28,10 @@ export interface DecideRouteAttempt {
16
28
  retryAfterMs?: number;
17
29
  latencyMs: number;
18
30
  error?: string;
31
+ responseSource?: "tool_call" | "content_fallback" | "content";
32
+ actionsStringDiagnostic?: ActionsStringDiagnostic;
33
+ /** Scheduler admission categories, never free-form provider error text. */
34
+ admissionReasons?: Array<"request_budget" | "token_budget" | "concurrency" | "shared_key_cooldown" | "model_cooldown">;
19
35
  }
20
36
  export interface DecideRouteMeta {
21
37
  policyVersion: string;
@@ -29,14 +45,13 @@ export type DecideResult = {
29
45
  ok: true;
30
46
  text: string;
31
47
  responseSource?: "tool_call" | "content_fallback" | "content";
32
- usage?: {
33
- promptTokens: number;
34
- completionTokens: number;
35
- };
48
+ usage?: ProviderUsage;
36
49
  route?: DecideRouteMeta;
37
50
  } | {
38
51
  ok: false;
39
52
  error: string;
53
+ failureClass?: "malformed";
54
+ usage?: ProviderUsage;
40
55
  status?: number;
41
56
  retryAfterMs?: number;
42
57
  retryableHttpFailure?: true;
@@ -58,10 +73,13 @@ export interface ProviderEnv {
58
73
  /** Keep direct and routed cycle evidence consistent about NVIDIA backpressure. */
59
74
  export declare function classifyProviderFailure(result: Extract<DecideResult, {
60
75
  ok: false;
61
- }>): "capacity" | "permanent" | "transient";
62
- export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch): Provider;
76
+ }>): "capacity" | "permanent" | "transient" | "malformed";
77
+ export interface ProviderRouteOptions {
78
+ nemotronJsonContent?: boolean | (() => boolean);
79
+ }
80
+ export declare function selectProvider(spec: AgentSpec, env: ProviderEnv, fetchFn?: typeof fetch, options?: ProviderRouteOptions): Provider;
63
81
  export declare function providerForRoute(route: {
64
82
  provider: ProviderName;
65
83
  model: string;
66
84
  baseUrl?: string | null;
67
- }, apiKey: string, fetchFn?: typeof fetch): Provider;
85
+ }, apiKey: string, fetchFn?: typeof fetch, options?: ProviderRouteOptions): Provider;