@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 +51 -0
- package/README.md +37 -8
- package/dist/agent/client.d.ts +2 -0
- package/dist/agent/client.js +4 -0
- package/dist/agent/decisionProbe.d.ts +17 -0
- package/dist/agent/decisionProbe.js +70 -0
- package/dist/agent/decisionValidator.js +23 -0
- package/dist/agent/engine.d.ts +4 -1
- package/dist/agent/engine.js +7 -1
- package/dist/agent/observe.js +19 -1
- package/dist/agent/prompt.d.ts +3 -1
- package/dist/agent/prompt.js +98 -29
- package/dist/agent/providerCapabilities.d.ts +20 -0
- package/dist/agent/providerCapabilities.js +67 -0
- package/dist/agent/providers.d.ts +29 -1
- package/dist/agent/providers.js +50 -25
- package/dist/agent/resolve.d.ts +1 -1
- package/dist/agent/resolve.js +20 -1
- package/dist/agent/runner.js +63 -18
- package/dist/agent/skill.js +6 -0
- package/dist/agent/skillValidator.js +7 -0
- package/dist/agent/state.js +1 -1
- package/dist/agent/strictLint.js +1 -0
- package/dist/agent/types.d.ts +24 -0
- package/dist/http.js +21 -0
- package/dist/tools.js +10 -10
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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`.
|
|
287
|
-
|
|
288
|
-
|
|
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.
|
package/dist/agent/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/agent/client.js
CHANGED
|
@@ -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 &&
|
package/dist/agent/engine.d.ts
CHANGED
|
@@ -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";
|
package/dist/agent/engine.js
CHANGED
|
@@ -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";
|
package/dist/agent/observe.js
CHANGED
|
@@ -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 =
|
|
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 = {
|
package/dist/agent/prompt.d.ts
CHANGED
package/dist/agent/prompt.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
?
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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;
|
package/dist/agent/providers.js
CHANGED
|
@@ -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 =
|
|
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
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
}
|
package/dist/agent/resolve.d.ts
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/agent/resolve.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/agent/runner.js
CHANGED
|
@@ -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 =
|
|
533
|
-
?
|
|
534
|
-
:
|
|
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:
|
|
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
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
//
|
|
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
|
-
|
|
560
|
-
|
|
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(`
|
|
608
|
+
log(`provider hold: ${hold.provider}/${hold.model} — ${res.error.slice(0, 120)}`);
|
|
563
609
|
return {
|
|
564
610
|
decision: "skip",
|
|
565
|
-
skipReason: `
|
|
611
|
+
skipReason: `provider hold: ${res.error}`,
|
|
566
612
|
planned: [],
|
|
567
613
|
modelFailed: true,
|
|
568
|
-
|
|
569
|
-
disabledReason: state.disabledReason,
|
|
614
|
+
providerHold: hold,
|
|
570
615
|
live,
|
|
571
616
|
...meter,
|
|
572
617
|
decisionType: "model_error",
|
package/dist/agent/skill.js
CHANGED
|
@@ -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) {
|
package/dist/agent/state.js
CHANGED
|
@@ -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);
|
package/dist/agent/strictLint.js
CHANGED
package/dist/agent/types.d.ts
CHANGED
|
@@ -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
|
|
975
|
-
"
|
|
976
|
-
"
|
|
977
|
-
"
|
|
978
|
-
"
|
|
979
|
-
"
|
|
980
|
-
"
|
|
981
|
-
"
|
|
982
|
-
"
|
|
983
|
-
"
|
|
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.
|
|
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",
|