@coinrithm/mcp-trading 0.7.8 → 0.7.9
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 +95 -0
- package/README.md +86 -69
- package/dist/agent/capitalSizing.js +17 -4
- package/dist/agent/client.d.ts +8 -1
- package/dist/agent/client.js +98 -39
- package/dist/agent/decisionReceipt.d.ts +4 -1
- package/dist/agent/decisionReceipt.js +27 -3
- package/dist/agent/gate.js +14 -11
- package/dist/agent/providers.d.ts +2 -1
- package/dist/agent/providers.js +109 -19
- package/dist/agent/runner.js +9 -4
- package/dist/agent/state.js +17 -2
- package/dist/agent/templates.js +4 -0
- package/dist/client.js +3 -2
- package/dist/http.d.ts +7 -1
- package/dist/http.js +36 -16
- package/dist/httpCompletion.d.ts +33 -0
- package/dist/httpCompletion.js +220 -0
- package/dist/retryAfter.d.ts +1 -0
- package/dist/retryAfter.js +16 -0
- package/dist/tools.js +19 -1
- package/package.json +4 -2
|
@@ -49,6 +49,7 @@ export function buildDecisionInputRecord(input) {
|
|
|
49
49
|
const budget = buildDailyRiskBudget(input.spec, input.state);
|
|
50
50
|
const record = {
|
|
51
51
|
version: "coinrithm.decision-input.v1",
|
|
52
|
+
projectionVersion: "coinrithm.decision-input-projection.v2",
|
|
52
53
|
visibility: "private",
|
|
53
54
|
completeness: "partial",
|
|
54
55
|
phase: input.phase,
|
|
@@ -149,6 +150,12 @@ export function buildDecisionInputRecord(input) {
|
|
|
149
150
|
ema20AboveEma50: bool(obj(r.indicators).ema20AboveEma50),
|
|
150
151
|
brokeRecentHigh: bool(obj(r.indicators).brokeRecentHigh),
|
|
151
152
|
brokeRecentLow: bool(obj(r.indicators).brokeRecentLow),
|
|
153
|
+
bollinger: numeric(obj(obj(r.indicators).bollinger), [
|
|
154
|
+
"upper",
|
|
155
|
+
"mid",
|
|
156
|
+
"lower",
|
|
157
|
+
]),
|
|
158
|
+
recent20: numeric(obj(obj(r.indicators).recent20), ["high", "low"]),
|
|
152
159
|
},
|
|
153
160
|
fundamentals: numeric(obj(r.fundamentals), [
|
|
154
161
|
"marketCapRank",
|
|
@@ -156,11 +163,18 @@ export function buildDecisionInputRecord(input) {
|
|
|
156
163
|
"volume24hUsd",
|
|
157
164
|
]),
|
|
158
165
|
}));
|
|
166
|
+
// Context-only movers are not executable candidates. Preserve their order
|
|
167
|
+
// and numeric facts without copying provider names or arbitrary free text.
|
|
168
|
+
add("universeMovers", obs.universeMovers, (r) => ({
|
|
169
|
+
symbol: id(r.symbol, 20),
|
|
170
|
+
...numeric(r, ["change24hPct", "priceUsd"]),
|
|
171
|
+
}));
|
|
159
172
|
add("futuresPositions", obs.openPositions, (r) => ({
|
|
160
173
|
id: num(r.id),
|
|
161
174
|
symbol: id(r.symbol, 20),
|
|
162
175
|
coinId: id(r.coinId, 32),
|
|
163
176
|
side: code(r.side, ["long", "short"]),
|
|
177
|
+
openedAt: sourceTimestamp(r.openedAt) ?? null,
|
|
164
178
|
...numeric(r, [
|
|
165
179
|
"leverage",
|
|
166
180
|
"marginMusd",
|
|
@@ -238,7 +252,6 @@ export function buildDecisionInputRecord(input) {
|
|
|
238
252
|
["news", obs.news],
|
|
239
253
|
["pmResolutions", obs.pmResolutions],
|
|
240
254
|
["newClosedTrades", obs.newClosedTrades],
|
|
241
|
-
["universeMovers", obs.universeMovers],
|
|
242
255
|
["journal", input.state.journal],
|
|
243
256
|
]) {
|
|
244
257
|
record.counts[name] = {
|
|
@@ -247,7 +260,7 @@ export function buildDecisionInputRecord(input) {
|
|
|
247
260
|
omitted: arr(values).length,
|
|
248
261
|
};
|
|
249
262
|
}
|
|
250
|
-
record.omissions.push("
|
|
263
|
+
record.omissions.push("unlisted_fields_excluded");
|
|
251
264
|
if (Object.values(record.counts).some((v) => v.omitted > 0))
|
|
252
265
|
record.omissions.push("list_rows_omitted");
|
|
253
266
|
// Leave room for fixed outcome/error metadata. Drop the largest remaining
|
|
@@ -303,6 +316,7 @@ const OMISSIONS = [
|
|
|
303
316
|
"config_fingerprint_unavailable",
|
|
304
317
|
"observation_not_available",
|
|
305
318
|
"unlisted_fields_and_nested_indicators_excluded",
|
|
319
|
+
"unlisted_fields_excluded",
|
|
306
320
|
"list_rows_omitted",
|
|
307
321
|
"byte_budget_exceeded",
|
|
308
322
|
"evidence_capture_failed",
|
|
@@ -327,6 +341,7 @@ const LIST_KEYS = {
|
|
|
327
341
|
"symbol",
|
|
328
342
|
"coinId",
|
|
329
343
|
"side",
|
|
344
|
+
"openedAt",
|
|
330
345
|
"leverage",
|
|
331
346
|
"marginMusd",
|
|
332
347
|
"unrealizedPnlMusd",
|
|
@@ -361,6 +376,7 @@ const LIST_KEYS = {
|
|
|
361
376
|
"decisionSupport",
|
|
362
377
|
],
|
|
363
378
|
signals: ["symbol", "kind", "bias", "strength", "held"],
|
|
379
|
+
universeMovers: ["symbol", "change24hPct", "priceUsd"],
|
|
364
380
|
};
|
|
365
381
|
const NESTED_KEYS = {
|
|
366
382
|
freshness: ["status", "ageSeconds", "asOf", "basis"],
|
|
@@ -391,7 +407,11 @@ const NESTED_KEYS = {
|
|
|
391
407
|
"ema20AboveEma50",
|
|
392
408
|
"brokeRecentHigh",
|
|
393
409
|
"brokeRecentLow",
|
|
410
|
+
"bollinger",
|
|
411
|
+
"recent20",
|
|
394
412
|
],
|
|
413
|
+
bollinger: ["upper", "mid", "lower"],
|
|
414
|
+
recent20: ["high", "low"],
|
|
395
415
|
fundamentals: ["marketCapRank", "marketCapUsd", "volume24hUsd"],
|
|
396
416
|
};
|
|
397
417
|
function keysOnly(value, keys) {
|
|
@@ -408,7 +428,7 @@ function validRow(value, keys) {
|
|
|
408
428
|
return true;
|
|
409
429
|
if (NESTED_KEYS[key])
|
|
410
430
|
return validRow(v, NESTED_KEYS[key]);
|
|
411
|
-
if (key === "asOf" || key === "assessedAt")
|
|
431
|
+
if (key === "asOf" || key === "assessedAt" || key === "openedAt")
|
|
412
432
|
return sourceTimestamp(v) === v;
|
|
413
433
|
if (key === "basis")
|
|
414
434
|
return code(v, FRESHNESS_BASES) !== null;
|
|
@@ -489,6 +509,7 @@ export function sanitizeDecisionInputRecord(value) {
|
|
|
489
509
|
const r = JSON.parse(encoded);
|
|
490
510
|
if (!keysOnly(r, [
|
|
491
511
|
"version",
|
|
512
|
+
"projectionVersion",
|
|
492
513
|
"visibility",
|
|
493
514
|
"completeness",
|
|
494
515
|
"phase",
|
|
@@ -506,6 +527,9 @@ export function sanitizeDecisionInputRecord(value) {
|
|
|
506
527
|
"omissions",
|
|
507
528
|
]))
|
|
508
529
|
return undefined;
|
|
530
|
+
if (r.projectionVersion !== undefined &&
|
|
531
|
+
r.projectionVersion !== "coinrithm.decision-input-projection.v2")
|
|
532
|
+
return undefined;
|
|
509
533
|
if (r.version !== "coinrithm.decision-input.v1" ||
|
|
510
534
|
r.visibility !== "private" ||
|
|
511
535
|
r.completeness !== "partial")
|
package/dist/agent/gate.js
CHANGED
|
@@ -58,27 +58,27 @@ export function evaluateGate(observation, state, policy, nowMs) {
|
|
|
58
58
|
// evaluate PREDICTION MARKETS — they carry edge even when crypto prices are flat,
|
|
59
59
|
// so an agent on a quiet tape shouldn't go dark on PM. At most once per cooldown
|
|
60
60
|
// (gated on the last LLM call, which any fire resets), so it's not every cycle.
|
|
61
|
+
let pmPeriodic = false;
|
|
61
62
|
if (codeList.length === 0) {
|
|
62
63
|
const pmAvailable = observation.pmMarkets.length > 0;
|
|
63
64
|
const sinceLastCall = state.lastLlmCallAt == null ? Infinity : nowMs - state.lastLlmCallAt;
|
|
64
65
|
if (pmAvailable &&
|
|
65
66
|
policy.pmEvalCooldownMinutes > 0 &&
|
|
66
67
|
sinceLastCall >= policy.pmEvalCooldownMinutes * 60_000) {
|
|
68
|
+
pmPeriodic = true;
|
|
69
|
+
codeList.push("PM_PERIODIC");
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
67
72
|
return {
|
|
68
|
-
fire:
|
|
69
|
-
codes: [
|
|
70
|
-
reason: "
|
|
73
|
+
fire: false,
|
|
74
|
+
codes: [],
|
|
75
|
+
reason: "no trigger (flat tape, no open position)",
|
|
71
76
|
};
|
|
72
77
|
}
|
|
73
|
-
return {
|
|
74
|
-
fire: false,
|
|
75
|
-
codes: [],
|
|
76
|
-
reason: "no trigger (flat tape, no open position)",
|
|
77
|
-
};
|
|
78
78
|
}
|
|
79
79
|
// A real trigger exists. Open positions are NEVER starved by budget/debounce
|
|
80
80
|
// (managing a live position is always allowed); the caps below only throttle
|
|
81
|
-
// fresh entry-only cycles
|
|
81
|
+
// fresh entry-only cycles, including periodic PM evaluations.
|
|
82
82
|
if (!hasPosition) {
|
|
83
83
|
if (policy.maxLlmCallsPerHour > 0) {
|
|
84
84
|
const recent = (state.llmCallTimestamps ?? []).filter((t) => nowMs - t < 3_600_000);
|
|
@@ -90,7 +90,8 @@ export function evaluateGate(observation, state, policy, nowMs) {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
// PM already passed its own cooldown; debounce only crypto-entry triggers.
|
|
94
|
+
if (!pmPeriodic && policy.debounceMinutes > 0) {
|
|
94
95
|
const fp = [...codeList].sort().join(",");
|
|
95
96
|
if (state.lastTriggerFingerprint === fp &&
|
|
96
97
|
state.lastLlmCallAt != null &&
|
|
@@ -106,7 +107,9 @@ export function evaluateGate(observation, state, policy, nowMs) {
|
|
|
106
107
|
return {
|
|
107
108
|
fire: true,
|
|
108
109
|
codes: codeList,
|
|
109
|
-
reason:
|
|
110
|
+
reason: pmPeriodic
|
|
111
|
+
? "PM periodic eval (quiet price tape)"
|
|
112
|
+
: `triggers: ${codeList.join(",")}`,
|
|
110
113
|
};
|
|
111
114
|
}
|
|
112
115
|
// Record that this cycle spent an LLM call — feeds the budget + debounce next
|
|
@@ -20,7 +20,7 @@ export interface DecideRouteMeta {
|
|
|
20
20
|
profile: "fast" | "strong" | "configured";
|
|
21
21
|
effectiveProvider?: string;
|
|
22
22
|
effectiveModel?: string;
|
|
23
|
-
reason: "configured" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
|
|
23
|
+
reason: "configured" | "configured_direct" | "circuit_fallback" | "capacity_fallback" | "provider_fallback" | "malformed_fallback" | "byo";
|
|
24
24
|
attempts: DecideRouteAttempt[];
|
|
25
25
|
}
|
|
26
26
|
export type DecideResult = {
|
|
@@ -36,6 +36,7 @@ export type DecideResult = {
|
|
|
36
36
|
error: string;
|
|
37
37
|
status?: number;
|
|
38
38
|
retryAfterMs?: number;
|
|
39
|
+
retryableHttpFailure?: true;
|
|
39
40
|
deferred?: boolean;
|
|
40
41
|
route?: DecideRouteMeta;
|
|
41
42
|
};
|
package/dist/agent/providers.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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 { retryAfterSeconds } from "../retryAfter.js";
|
|
5
6
|
import { chatShapeFor, buildChatBody, DECISION_TOOL_NAME, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
|
|
6
7
|
// NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
|
|
7
8
|
// endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
|
|
@@ -27,6 +28,7 @@ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai
|
|
|
27
28
|
// (the recurring Leo/70B timeout). A real hang still aborts -> retried next cadence.
|
|
28
29
|
// MUST stay below the scheduler's RUN_LOCK_SECONDS and HEARTBEAT_STALE_MS.
|
|
29
30
|
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
31
|
+
const RETRYABLE_SERVER_STATUSES = new Set([500, 502, 503, 504]);
|
|
30
32
|
// Per-route request quirks (reasoning toggles, token param, temperature) live
|
|
31
33
|
// in the capability table — providerCapabilities.ts is the single source; this
|
|
32
34
|
// module only assembles and sends.
|
|
@@ -58,22 +60,24 @@ function callError(err, timeoutMs) {
|
|
|
58
60
|
}
|
|
59
61
|
return err instanceof Error ? err.message : String(err);
|
|
60
62
|
}
|
|
63
|
+
// Error text reaches retained cycle evidence. Redact before truncating so a
|
|
64
|
+
// credential crossing the size boundary cannot leave a partial key behind.
|
|
65
|
+
function safeProviderError(text, apiKey) {
|
|
66
|
+
return (apiKey ? text.split(apiKey).join("[redacted]") : text)
|
|
67
|
+
.replace(/Bearer\s+[^\s"'<>\\]+/gi, "Bearer [redacted]")
|
|
68
|
+
.replace(/(?:nvapi-|crk_live_|sk-|sk_live_|ghp_|AIza)[A-Za-z0-9_-]+/g, "[redacted]")
|
|
69
|
+
.replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[redacted]")
|
|
70
|
+
.replace(/((?:api[_-]?key|access[_-]?token|password|secret)["']?\s*[:=]\s*["']?)[^\s"',}&<>]+/gi, "$1[redacted]")
|
|
71
|
+
.slice(0, 2000);
|
|
72
|
+
}
|
|
61
73
|
// Parse a Retry-After header (delta-seconds or HTTP-date) into ms, capped at
|
|
62
74
|
// one hour — a provider asking for more is treated as "an hour, then re-probe".
|
|
63
75
|
const RETRY_AFTER_CAP_MS = 3_600_000;
|
|
64
76
|
function retryAfterMs(res) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (Number.isFinite(secs) && secs >= 0) {
|
|
70
|
-
return Math.min(Math.round(secs * 1000), RETRY_AFTER_CAP_MS);
|
|
71
|
-
}
|
|
72
|
-
const at = Date.parse(raw);
|
|
73
|
-
if (!Number.isFinite(at))
|
|
74
|
-
return undefined;
|
|
75
|
-
const ms = at - Date.now();
|
|
76
|
-
return ms > 0 ? Math.min(ms, RETRY_AFTER_CAP_MS) : 0;
|
|
77
|
+
const seconds = retryAfterSeconds(res.headers.get("retry-after"));
|
|
78
|
+
return seconds === undefined
|
|
79
|
+
? undefined
|
|
80
|
+
: Math.min(Math.round(seconds * 1000), RETRY_AFTER_CAP_MS);
|
|
77
81
|
}
|
|
78
82
|
function envKey(provider, env) {
|
|
79
83
|
switch (provider) {
|
|
@@ -165,7 +169,7 @@ class AnthropicProvider {
|
|
|
165
169
|
ok: false,
|
|
166
170
|
// Cap the upstream body: it lands in agent_cycles.skip_reason, so an
|
|
167
171
|
// unbounded provider error page must not bloat the ledger row.
|
|
168
|
-
error: `anthropic HTTP ${res.status}: ${(await res.text()
|
|
172
|
+
error: `anthropic HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
|
|
169
173
|
status: res.status,
|
|
170
174
|
retryAfterMs: retryAfterMs(res),
|
|
171
175
|
};
|
|
@@ -186,9 +190,9 @@ class AnthropicProvider {
|
|
|
186
190
|
catch (err) {
|
|
187
191
|
return {
|
|
188
192
|
ok: false,
|
|
189
|
-
error: failureResponse
|
|
193
|
+
error: safeProviderError(failureResponse
|
|
190
194
|
? `anthropic HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
|
|
191
|
-
: callError(err, timeoutMs),
|
|
195
|
+
: callError(err, timeoutMs), this.apiKey),
|
|
192
196
|
...(failureResponse
|
|
193
197
|
? {
|
|
194
198
|
status: failureResponse.status,
|
|
@@ -240,9 +244,12 @@ class OpenAiCompatProvider {
|
|
|
240
244
|
ok: false,
|
|
241
245
|
// Cap the upstream body: it lands in agent_cycles.skip_reason, so an
|
|
242
246
|
// unbounded provider error page must not bloat the ledger row.
|
|
243
|
-
error: `provider HTTP ${res.status}: ${(await res.text()
|
|
247
|
+
error: `provider HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
|
|
244
248
|
status: res.status,
|
|
245
249
|
retryAfterMs: retryAfterMs(res),
|
|
250
|
+
...(RETRYABLE_SERVER_STATUSES.has(res.status)
|
|
251
|
+
? { retryableHttpFailure: true }
|
|
252
|
+
: {}),
|
|
246
253
|
};
|
|
247
254
|
}
|
|
248
255
|
const json = (await res.json());
|
|
@@ -263,9 +270,9 @@ class OpenAiCompatProvider {
|
|
|
263
270
|
catch (err) {
|
|
264
271
|
return {
|
|
265
272
|
ok: false,
|
|
266
|
-
error: failureResponse
|
|
273
|
+
error: safeProviderError(failureResponse
|
|
267
274
|
? `provider HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
|
|
268
|
-
: callError(err, timeoutMs),
|
|
275
|
+
: callError(err, timeoutMs), this.apiKey),
|
|
269
276
|
...(failureResponse
|
|
270
277
|
? {
|
|
271
278
|
status: failureResponse.status,
|
|
@@ -276,6 +283,86 @@ class OpenAiCompatProvider {
|
|
|
276
283
|
}
|
|
277
284
|
}
|
|
278
285
|
}
|
|
286
|
+
// A direct NVIDIA 5xx previously lost the whole cycle until the next cadence.
|
|
287
|
+
// Retry only an explicit server refusal, once, on the identical configured
|
|
288
|
+
// route. The shared router owns its own attempt/capacity budget and MUST NOT
|
|
289
|
+
// receive this wrapper. No auth/404/429, network, timeout or decision repair
|
|
290
|
+
// retries; no model substitution or request-parameter changes.
|
|
291
|
+
class SameModelRetryProvider {
|
|
292
|
+
delegate;
|
|
293
|
+
provider;
|
|
294
|
+
model;
|
|
295
|
+
label;
|
|
296
|
+
constructor(delegate, provider, model) {
|
|
297
|
+
this.delegate = delegate;
|
|
298
|
+
this.provider = provider;
|
|
299
|
+
this.model = model;
|
|
300
|
+
this.label = delegate.label;
|
|
301
|
+
}
|
|
302
|
+
async decide(input) {
|
|
303
|
+
const started = Date.now();
|
|
304
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
305
|
+
const first = await this.delegate.decide(input);
|
|
306
|
+
const firstFinished = Date.now();
|
|
307
|
+
if (first.ok || !first.retryableHttpFailure) {
|
|
308
|
+
return first;
|
|
309
|
+
}
|
|
310
|
+
const delayMs = Math.max(1000, first.retryAfterMs ?? 0);
|
|
311
|
+
// Honor the provider's cooldown; a long one waits for the next cycle. Both
|
|
312
|
+
// attempts and this backoff share the ORIGINAL deadline, never two 5m caps.
|
|
313
|
+
if (delayMs > 5000 || delayMs >= timeoutMs - (firstFinished - started)) {
|
|
314
|
+
return first;
|
|
315
|
+
}
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
317
|
+
const retryStarted = Date.now();
|
|
318
|
+
const remainingMs = timeoutMs - (retryStarted - started);
|
|
319
|
+
if (remainingMs <= 0)
|
|
320
|
+
return first;
|
|
321
|
+
const result = await this.delegate.decide({
|
|
322
|
+
...input,
|
|
323
|
+
timeoutMs: remainingMs,
|
|
324
|
+
});
|
|
325
|
+
const attempt = (res, latencyMs) => ({
|
|
326
|
+
provider: this.provider,
|
|
327
|
+
model: this.model,
|
|
328
|
+
outcome: res.ok ? "success" : "failed",
|
|
329
|
+
latencyMs,
|
|
330
|
+
...(!res.ok
|
|
331
|
+
? {
|
|
332
|
+
error: res.error,
|
|
333
|
+
status: res.status,
|
|
334
|
+
retryAfterMs: res.retryAfterMs,
|
|
335
|
+
...(res.status !== undefined
|
|
336
|
+
? {
|
|
337
|
+
failureClass: res.status === 429
|
|
338
|
+
? "capacity"
|
|
339
|
+
: res.status >= 500
|
|
340
|
+
? "transient"
|
|
341
|
+
: "permanent",
|
|
342
|
+
}
|
|
343
|
+
: {}),
|
|
344
|
+
}
|
|
345
|
+
: {}),
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
// Usage, when present, describes only the final response. The preceding
|
|
349
|
+
// 5xx did not report usage; attempt evidence is not total billed tokens
|
|
350
|
+
// or a certificate that returned text passed the trading-decision parser.
|
|
351
|
+
...result,
|
|
352
|
+
route: {
|
|
353
|
+
policyVersion: "coinrithm.configured-same-model-retry.v1",
|
|
354
|
+
profile: "configured",
|
|
355
|
+
effectiveProvider: this.provider,
|
|
356
|
+
effectiveModel: this.model,
|
|
357
|
+
reason: "configured_direct",
|
|
358
|
+
attempts: [
|
|
359
|
+
attempt(first, firstFinished - started),
|
|
360
|
+
attempt(result, Date.now() - retryStarted),
|
|
361
|
+
],
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
279
366
|
// Build the provider from the spec's model block + env key. Throws a clear
|
|
280
367
|
// error if no model is configured (self-host requires one) or the env key is
|
|
281
368
|
// missing. fetch is injectable for tests.
|
|
@@ -308,7 +395,10 @@ export function selectProvider(spec, env, fetchFn = fetch) {
|
|
|
308
395
|
if (!resolvedBase) {
|
|
309
396
|
throw new Error("openai-compatible provider needs model.baseUrl");
|
|
310
397
|
}
|
|
311
|
-
|
|
398
|
+
const direct = new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
|
|
399
|
+
return provider === "nvidia"
|
|
400
|
+
? new SameModelRetryProvider(direct, provider, name)
|
|
401
|
+
: direct;
|
|
312
402
|
}
|
|
313
403
|
// Build a provider for an EXPLICIT route + raw key (no spec, no env) — the
|
|
314
404
|
// decision probe's entry point. Same classes as selectProvider, so a probe
|
package/dist/agent/runner.js
CHANGED
|
@@ -1291,13 +1291,18 @@ async function runCycleCore(deps, capture) {
|
|
|
1291
1291
|
state.consecutiveExecFailures =
|
|
1292
1292
|
anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
|
|
1293
1293
|
state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1294
|
+
// Memory describes completed moves, not policy acceptance. A backend rejection,
|
|
1295
|
+
// lost response, or dry-run plan must not become "opened" / "trailed stop" in
|
|
1296
|
+
// the next model prompt. Attempts remain in planned[] and the audit ledger.
|
|
1296
1297
|
const moves = planned
|
|
1297
|
-
.filter((p) => p.accepted)
|
|
1298
|
+
.filter((p) => p.accepted && p.executed === true)
|
|
1298
1299
|
.map((p) => summarizeAction(p.action));
|
|
1299
1300
|
if (moves.length > 0) {
|
|
1300
|
-
|
|
1301
|
+
// A cycle-wide rationale may describe a failed/rejected sibling action.
|
|
1302
|
+
// Keep it only when every proposed action executed; per-action theses are
|
|
1303
|
+
// already included by summarizeAction for confirmed opens.
|
|
1304
|
+
const confirmedRationale = moves.length === planned.length ? rationale : undefined;
|
|
1305
|
+
const did = `${moves.join("; ")}${confirmedRationale ? ` — ${confirmedRationale.slice(0, 90)}` : ""}`;
|
|
1301
1306
|
state.journal = [
|
|
1302
1307
|
...(state.journal ?? []),
|
|
1303
1308
|
{ at: observation.asOf, did },
|
package/dist/agent/state.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Local run state: the cursor, dedupe set, daily counters, and the kill-switch
|
|
2
2
|
// inputs. Persisted to a JSON file so a re-run resumes where it left off.
|
|
3
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, rmSync, } from "node:fs";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { dirname } from "node:path";
|
|
5
6
|
import { dayKey } from "./util.js";
|
|
6
7
|
import { asObj, asNum } from "./extract.js";
|
|
@@ -59,8 +60,22 @@ export function loadState(file, runId) {
|
|
|
59
60
|
export function saveState(file, state) {
|
|
60
61
|
if (!file)
|
|
61
62
|
return;
|
|
63
|
+
const serialized = JSON.stringify(state, null, 2);
|
|
62
64
|
mkdirSync(dirname(file), { recursive: true });
|
|
63
|
-
|
|
65
|
+
// A sibling file keeps rename on the same filesystem. Readers see either
|
|
66
|
+
// the previous complete state or the new one, never a truncated JSON write.
|
|
67
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
68
|
+
try {
|
|
69
|
+
writeFileSync(temporary, serialized, {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
flag: "wx",
|
|
72
|
+
mode: 0o600,
|
|
73
|
+
});
|
|
74
|
+
renameSync(temporary, file);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
rmSync(temporary, { force: true });
|
|
78
|
+
}
|
|
64
79
|
}
|
|
65
80
|
export function rollDay(state) {
|
|
66
81
|
const today = dayKey();
|
package/dist/agent/templates.js
CHANGED
|
@@ -182,6 +182,10 @@ export function ejectFiles(fm, body) {
|
|
|
182
182
|
};
|
|
183
183
|
if (fm.sizing)
|
|
184
184
|
agentFm.sizing = fm.sizing;
|
|
185
|
+
if (fm.capitalSizing !== undefined)
|
|
186
|
+
agentFm.capitalSizing = fm.capitalSizing;
|
|
187
|
+
if (fm.triggerPolicy !== undefined)
|
|
188
|
+
agentFm.triggerPolicy = fm.triggerPolicy;
|
|
185
189
|
if (fm.objective)
|
|
186
190
|
agentFm.objective = fm.objective;
|
|
187
191
|
if (fm.capabilities)
|
package/dist/client.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// IMPORTANT: this module must NEVER write to stdout (stdout is the MCP JSON-RPC
|
|
19
19
|
// channel). All diagnostics go to stderr via the logger below.
|
|
20
|
+
import { retryAfterSeconds } from "./retryAfter.js";
|
|
20
21
|
export const DEFAULT_BASE_URL = "https://api.coinrithm.com";
|
|
21
22
|
export function log(...args) {
|
|
22
23
|
// stderr only — stdout is reserved for the MCP protocol.
|
|
@@ -139,12 +140,12 @@ export class CoinRithmClient {
|
|
|
139
140
|
if (res.status === 429) {
|
|
140
141
|
// Surface the back-off contract so an agent can pace itself instead of
|
|
141
142
|
// hammering: 120 req/min per key baseline, 20 trade-writes/min.
|
|
142
|
-
const retryAfter =
|
|
143
|
+
const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
|
|
143
144
|
data = {
|
|
144
145
|
...(typeof data === "object" && data !== null
|
|
145
146
|
? data
|
|
146
147
|
: { error: String(data) }),
|
|
147
|
-
retryAfterSeconds:
|
|
148
|
+
retryAfterSeconds: retryAfter ?? null,
|
|
148
149
|
hint: "Rate limited. Wait retryAfterSeconds (or the Retry-After header) before retrying; pace future calls using the RateLimit-Remaining response header.",
|
|
149
150
|
};
|
|
150
151
|
}
|
package/dist/http.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { CoinRithmClient } from "./client.js";
|
|
4
|
+
import { type CompletionLogger } from "./httpCompletion.js";
|
|
5
|
+
export declare function createHttpApp(client: CoinRithmClient, options?: {
|
|
6
|
+
completionLogger?: CompletionLogger;
|
|
7
|
+
createServer?: () => McpServer;
|
|
8
|
+
}): import("express-serve-static-core").Express;
|
package/dist/http.js
CHANGED
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
// forwards as `extra.authInfo`, giving requestKey() a second source. Either
|
|
25
25
|
// way the caller's own key — and only that key — is used for their tool call.
|
|
26
26
|
// - Unauthenticated MCP initialization and tool-list introspection are allowed
|
|
27
|
-
// so registries can verify the server.
|
|
28
|
-
// a structured 401
|
|
27
|
+
// so registries can verify the server. Public data tools are also keyless;
|
|
28
|
+
// protected tools return a structured 401 when their key is missing.
|
|
29
29
|
//
|
|
30
30
|
// Config (env):
|
|
31
31
|
// COINRITHM_API_URL (optional) upstream base URL (default production).
|
|
@@ -35,15 +35,23 @@
|
|
|
35
35
|
// the correct isolation model for a multi-user, per-request-keyed surface — no
|
|
36
36
|
// session state is shared between users.
|
|
37
37
|
import express from "express";
|
|
38
|
+
import { pathToFileURL } from "node:url";
|
|
38
39
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
39
40
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
40
41
|
import { CoinRithmClient, bearerFromHeader, loadHttpConfig, log, } from "./client.js";
|
|
41
42
|
import { registerTools } from "./tools.js";
|
|
42
43
|
import { SERVER_VERSION } from "./version.js";
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
import { observeHttpCompletion, } from "./httpCompletion.js";
|
|
45
|
+
// Factory permits isolated localhost SDK tests without opening a listener on import.
|
|
46
|
+
export function createHttpApp(client, options = {}) {
|
|
46
47
|
const app = express();
|
|
48
|
+
const completions = new WeakMap();
|
|
49
|
+
app.use((req, res, next) => {
|
|
50
|
+
if (req.method === "POST" && /^\/mcp\/?$/i.test(req.path)) {
|
|
51
|
+
completions.set(res, observeHttpCompletion(req, res, options.completionLogger));
|
|
52
|
+
}
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
47
55
|
app.use(express.json());
|
|
48
56
|
// Lightweight, unauthenticated liveness probe (handy for Coolify/uptime checks).
|
|
49
57
|
// Keep `/healthz` as the deployment contract and expose `/health` as a
|
|
@@ -110,8 +118,8 @@ async function main() {
|
|
|
110
118
|
// Per-request auth: read THIS caller's key from the Authorization header,
|
|
111
119
|
// or from Smithery's non-reserved forwarding header.
|
|
112
120
|
// It is optional at the transport layer so registries can initialize the
|
|
113
|
-
// server and list tool schemas.
|
|
114
|
-
// a structured 401 if
|
|
121
|
+
// server and list tool schemas. Public data tools are keyless; protected
|
|
122
|
+
// tool handlers return a structured 401 if their key is missing.
|
|
115
123
|
const apiKey = bearerFromHeader(req.headers.authorization) ??
|
|
116
124
|
bearerFromHeader(req.headers["x-coinrithm-api-key"]);
|
|
117
125
|
// Belt-and-suspenders: also expose the token via the SDK's authInfo channel.
|
|
@@ -120,11 +128,13 @@ async function main() {
|
|
|
120
128
|
if (apiKey) {
|
|
121
129
|
req.auth = { token: apiKey, clientId: "coinrithm-key", scopes: [] };
|
|
122
130
|
}
|
|
123
|
-
const server =
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
const server = options.createServer?.() ??
|
|
132
|
+
new McpServer({
|
|
133
|
+
name: "coinrithm-trading",
|
|
134
|
+
version: SERVER_VERSION,
|
|
135
|
+
});
|
|
136
|
+
if (!options.createServer)
|
|
137
|
+
registerTools(server, client);
|
|
128
138
|
const transport = new StreamableHTTPServerTransport({
|
|
129
139
|
sessionIdGenerator: undefined, // stateless: no cross-request/user state
|
|
130
140
|
});
|
|
@@ -134,6 +144,7 @@ async function main() {
|
|
|
134
144
|
});
|
|
135
145
|
try {
|
|
136
146
|
await server.connect(transport);
|
|
147
|
+
completions.get(res)?.attach(transport);
|
|
137
148
|
// The transport reads req.headers (→ extra.requestInfo) and req.auth
|
|
138
149
|
// (→ extra.authInfo); tools.ts picks up the caller's key from there.
|
|
139
150
|
await transport.handleRequest(req, res, req.body);
|
|
@@ -149,13 +160,22 @@ async function main() {
|
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
162
|
});
|
|
163
|
+
return app;
|
|
164
|
+
}
|
|
165
|
+
async function main() {
|
|
166
|
+
const config = loadHttpConfig(); // no global key — keys arrive per request
|
|
167
|
+
const client = new CoinRithmClient(config); // constructed WITHOUT a default key
|
|
168
|
+
const app = createHttpApp(client);
|
|
152
169
|
const port = Number(process.env.PORT) || 8787;
|
|
153
170
|
app.listen(port, () => {
|
|
154
171
|
log(`HTTP MCP listening on :${port}/mcp (multi-user, per-request key). ` +
|
|
155
172
|
`upstream=${config.baseUrl}. Paper only.`);
|
|
156
173
|
});
|
|
157
174
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
if (process.argv[1] &&
|
|
176
|
+
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
177
|
+
main().catch((err) => {
|
|
178
|
+
log("fatal:", err instanceof Error ? err.message : err);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
3
|
+
export declare const COMPLETION_TOOL_NAMES: readonly ["whoami", "get_portfolio", "get_wallet", "list_open_orders", "get_positions", "resolve_symbol", "get_equity_curve", "get_my_trades", "get_market_context", "get_candles", "discover_pm_markets", "get_performance", "get_agent_ledger", "export_agent_ledger", "export_run_evidence", "get_arena_leaderboard", "get_arena_agent", "futures_quote", "pm_quote", "spot_quote", "place_spot_order", "cancel_spot_order", "open_futures_position", "set_futures_sl_tp", "close_futures_position", "open_pm_position", "report_pm_opportunity", "pm_data_overview", "pm_data_sources", "pm_data_sources_health", "pm_data_events", "pm_data_event", "pm_data_whales", "pm_data_disagreements", "pm_data_calibration", "pm_data_canonical", "pm_data_volume_history", "get_crypto_movers"];
|
|
4
|
+
type Operation = "initialize" | "tools_list" | "tools_call" | "notification" | "other" | "invalid";
|
|
5
|
+
type Outcome = "result" | "tool_error" | "protocol_error" | "no_response" | "not_applicable";
|
|
6
|
+
type ToolName = (typeof COMPLETION_TOOL_NAMES)[number] | "unknown" | null;
|
|
7
|
+
type RpcCompletion = {
|
|
8
|
+
operation: Operation;
|
|
9
|
+
tool: ToolName;
|
|
10
|
+
rpc_outcome: Outcome;
|
|
11
|
+
result_http_status: number | null;
|
|
12
|
+
result_ok: boolean | null;
|
|
13
|
+
};
|
|
14
|
+
export type HttpCompletionRecord = Readonly<RpcCompletion & {
|
|
15
|
+
event: "mcp_completion";
|
|
16
|
+
schema_version: 1;
|
|
17
|
+
completed_at: string;
|
|
18
|
+
duration_ms: number;
|
|
19
|
+
service_version: string;
|
|
20
|
+
transport: "streamable_http";
|
|
21
|
+
credential_supplied: boolean;
|
|
22
|
+
http_status: number | null;
|
|
23
|
+
delivery: "finished" | "aborted";
|
|
24
|
+
}>;
|
|
25
|
+
export type CompletionLogger = (line: string) => void;
|
|
26
|
+
/** Attach before JSON parsing; malformed/rejected HTTP requests remain invalid,
|
|
27
|
+
* no_response, with their actual HTTP status. Do not read bodies or raw errors.
|
|
28
|
+
* Authentication and caller origin are deliberately NOT inferred from headers.
|
|
29
|
+
*/
|
|
30
|
+
export declare function observeHttpCompletion(req: IncomingMessage, res: ServerResponse, logger?: CompletionLogger): {
|
|
31
|
+
attach: (transport: Transport) => void;
|
|
32
|
+
};
|
|
33
|
+
export {};
|