@coinrithm/mcp-trading 0.7.7 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +86 -69
  3. package/dist/agent/act.js +19 -6
  4. package/dist/agent/capitalSizing.d.ts +32 -0
  5. package/dist/agent/capitalSizing.js +270 -0
  6. package/dist/agent/client.d.ts +8 -1
  7. package/dist/agent/client.js +98 -39
  8. package/dist/agent/decision.d.ts +392 -0
  9. package/dist/agent/decision.js +177 -0
  10. package/dist/agent/decisionReceipt.d.ts +48 -0
  11. package/dist/agent/decisionReceipt.js +619 -0
  12. package/dist/agent/decisionValidator.d.ts +19 -2
  13. package/dist/agent/decisionValidator.js +74 -3
  14. package/dist/agent/engine.d.ts +1 -0
  15. package/dist/agent/engine.js +1 -0
  16. package/dist/agent/gate.js +14 -11
  17. package/dist/agent/observe.js +175 -34
  18. package/dist/agent/pmContext.d.ts +13 -0
  19. package/dist/agent/pmContext.js +136 -0
  20. package/dist/agent/prompt.d.ts +11 -1
  21. package/dist/agent/prompt.js +135 -7
  22. package/dist/agent/providerCapabilities.d.ts +3 -0
  23. package/dist/agent/providerCapabilities.js +41 -3
  24. package/dist/agent/providers.d.ts +2 -1
  25. package/dist/agent/providers.js +222 -86
  26. package/dist/agent/resolve.js +1 -0
  27. package/dist/agent/runner.d.ts +4 -1
  28. package/dist/agent/runner.js +369 -38
  29. package/dist/agent/scorecard.js +7 -1
  30. package/dist/agent/skill.js +17 -0
  31. package/dist/agent/skillValidator.d.ts +1 -0
  32. package/dist/agent/skillValidator.js +56 -0
  33. package/dist/agent/state.js +23 -2
  34. package/dist/agent/strictLint.js +19 -0
  35. package/dist/agent/templates.js +4 -0
  36. package/dist/agent/thesis.d.ts +40 -0
  37. package/dist/agent/thesis.js +319 -0
  38. package/dist/agent/types.d.ts +127 -0
  39. package/dist/client.js +3 -2
  40. package/dist/http.d.ts +7 -1
  41. package/dist/http.js +36 -16
  42. package/dist/httpCompletion.d.ts +33 -0
  43. package/dist/httpCompletion.js +220 -0
  44. package/dist/retryAfter.d.ts +1 -0
  45. package/dist/retryAfter.js +16 -0
  46. package/dist/tools.js +19 -1
  47. package/package.json +4 -2
@@ -2,7 +2,8 @@
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
+ import { retryAfterSeconds } from "../retryAfter.js";
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>" }`.
8
9
  const NVIDIA_BASE_URL = CAP_NVIDIA_BASE_URL;
@@ -27,16 +28,27 @@ 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.
33
- // fetch with a hard timeout via AbortController. A custom fetchFn (tests) that
34
- // ignores `signal` still works — the timer just never fires for it.
35
- async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
35
+ // One deadline covers both headers AND response-body consumption. fetch resolves
36
+ // at headers, so clearing a fetch-only timer there leaves text/json unbounded.
37
+ // Abort native I/O and race the deadline as well: an injected implementation that
38
+ // ignores AbortSignal must still release the caller rather than its run lock.
39
+ async function withProviderTimeout(timeoutMs, operation) {
36
40
  const controller = new AbortController();
37
- const timer = setTimeout(() => controller.abort(), timeoutMs);
41
+ let timer;
42
+ const deadline = new Promise((_resolve, reject) => {
43
+ timer = setTimeout(() => {
44
+ reject(Object.assign(new Error("model deadline exceeded"), {
45
+ name: "AbortError",
46
+ }));
47
+ controller.abort();
48
+ }, timeoutMs);
49
+ });
38
50
  try {
39
- return await fetchFn(url, { ...init, signal: controller.signal });
51
+ return await Promise.race([operation(controller.signal), deadline]);
40
52
  }
41
53
  finally {
42
54
  clearTimeout(timer);
@@ -48,22 +60,24 @@ function callError(err, timeoutMs) {
48
60
  }
49
61
  return err instanceof Error ? err.message : String(err);
50
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
+ }
51
73
  // Parse a Retry-After header (delta-seconds or HTTP-date) into ms, capped at
52
74
  // one hour — a provider asking for more is treated as "an hour, then re-probe".
53
75
  const RETRY_AFTER_CAP_MS = 3_600_000;
54
76
  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;
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);
67
81
  }
68
82
  function envKey(provider, env) {
69
83
  switch (provider) {
@@ -131,44 +145,61 @@ class AnthropicProvider {
131
145
  }
132
146
  async decide(input) {
133
147
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
148
+ let failureResponse;
134
149
  try {
135
- const res = await fetchWithTimeout(this.fetchFn, "https://api.anthropic.com/v1/messages", {
136
- method: "POST",
137
- headers: {
138
- "x-api-key": this.apiKey,
139
- "anthropic-version": "2023-06-01",
140
- "content-type": "application/json",
141
- },
142
- body: JSON.stringify({
143
- model: this.model,
144
- max_tokens: input.maxTokens ?? 1024,
145
- system: input.system,
146
- messages: [{ role: "user", content: input.user }],
147
- }),
148
- }, timeoutMs);
149
- if (!res.ok)
150
- return {
151
- ok: false,
152
- // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
153
- // unbounded provider error page must not bloat the ledger row.
154
- error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
155
- status: res.status,
156
- retryAfterMs: retryAfterMs(res),
157
- };
158
- const json = (await res.json());
159
- const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
160
- const usage = json.usage
161
- ? {
162
- promptTokens: json.usage.input_tokens ?? 0,
163
- completionTokens: json.usage.output_tokens ?? 0,
150
+ return await withProviderTimeout(timeoutMs, async (signal) => {
151
+ const res = await this.fetchFn("https://api.anthropic.com/v1/messages", {
152
+ method: "POST",
153
+ signal,
154
+ headers: {
155
+ "x-api-key": this.apiKey,
156
+ "anthropic-version": "2023-06-01",
157
+ "content-type": "application/json",
158
+ },
159
+ body: JSON.stringify({
160
+ model: this.model,
161
+ max_tokens: input.maxTokens ?? 1024,
162
+ system: input.system,
163
+ messages: [{ role: "user", content: input.user }],
164
+ }),
165
+ });
166
+ if (!res.ok) {
167
+ failureResponse = res;
168
+ return {
169
+ ok: false,
170
+ // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
171
+ // unbounded provider error page must not bloat the ledger row.
172
+ error: `anthropic HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
173
+ status: res.status,
174
+ retryAfterMs: retryAfterMs(res),
175
+ };
164
176
  }
165
- : undefined;
166
- return text
167
- ? { ok: true, text, usage }
168
- : { ok: false, error: "anthropic returned empty content" };
177
+ const json = (await res.json());
178
+ const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
179
+ const usage = json.usage
180
+ ? {
181
+ promptTokens: json.usage.input_tokens ?? 0,
182
+ completionTokens: json.usage.output_tokens ?? 0,
183
+ }
184
+ : undefined;
185
+ return text
186
+ ? { ok: true, text, usage }
187
+ : { ok: false, error: "anthropic returned empty content" };
188
+ });
169
189
  }
170
190
  catch (err) {
171
- return { ok: false, error: callError(err, timeoutMs) };
191
+ return {
192
+ ok: false,
193
+ error: safeProviderError(failureResponse
194
+ ? `anthropic HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
195
+ : callError(err, timeoutMs), this.apiKey),
196
+ ...(failureResponse
197
+ ? {
198
+ status: failureResponse.status,
199
+ retryAfterMs: retryAfterMs(failureResponse),
200
+ }
201
+ : {}),
202
+ };
172
203
  }
173
204
  }
174
205
  }
@@ -190,46 +221,148 @@ class OpenAiCompatProvider {
190
221
  async decide(input) {
191
222
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
192
223
  const shape = chatShapeFor(this.provider, this.model, this.baseUrl);
224
+ let failureResponse;
193
225
  try {
194
- const res = await fetchWithTimeout(this.fetchFn, `${this.baseUrl}/chat/completions`, {
195
- method: "POST",
196
- headers: {
197
- Authorization: `Bearer ${this.apiKey}`,
198
- "content-type": "application/json",
199
- },
200
- body: JSON.stringify(buildChatBody(shape, {
201
- model: this.model,
202
- system: input.system,
203
- user: input.user,
204
- maxTokens: input.maxTokens ?? 1024,
205
- })),
206
- }, timeoutMs);
207
- if (!res.ok)
208
- return {
209
- ok: false,
210
- // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
211
- // unbounded provider error page must not bloat the ledger row.
212
- error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
213
- status: res.status,
214
- retryAfterMs: retryAfterMs(res),
215
- };
216
- const json = (await res.json());
217
- const text = json.choices?.[0]?.message?.content ?? "";
218
- const usage = json.usage
219
- ? {
220
- promptTokens: json.usage.prompt_tokens ?? 0,
221
- completionTokens: json.usage.completion_tokens ?? 0,
226
+ return await withProviderTimeout(timeoutMs, async (signal) => {
227
+ const res = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
228
+ method: "POST",
229
+ signal,
230
+ headers: {
231
+ Authorization: `Bearer ${this.apiKey}`,
232
+ "content-type": "application/json",
233
+ },
234
+ body: JSON.stringify(buildChatBody(shape, {
235
+ model: this.model,
236
+ system: input.system,
237
+ user: input.user,
238
+ maxTokens: input.maxTokens ?? 1024,
239
+ })),
240
+ });
241
+ if (!res.ok) {
242
+ failureResponse = res;
243
+ return {
244
+ ok: false,
245
+ // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
246
+ // unbounded provider error page must not bloat the ledger row.
247
+ error: `provider HTTP ${res.status}: ${safeProviderError(await res.text(), this.apiKey)}`,
248
+ status: res.status,
249
+ retryAfterMs: retryAfterMs(res),
250
+ ...(RETRYABLE_SERVER_STATUSES.has(res.status)
251
+ ? { retryableHttpFailure: true }
252
+ : {}),
253
+ };
222
254
  }
223
- : undefined;
224
- return text
225
- ? { ok: true, text, usage }
226
- : { ok: false, error: "provider returned empty content" };
255
+ const json = (await res.json());
256
+ const message = json.choices?.[0]?.message;
257
+ const decisionArguments = message?.tool_calls?.find((call) => call.function?.name === DECISION_TOOL_NAME)?.function?.arguments;
258
+ const text = decisionArguments ?? message?.content ?? "";
259
+ const usage = json.usage
260
+ ? {
261
+ promptTokens: json.usage.prompt_tokens ?? 0,
262
+ completionTokens: json.usage.completion_tokens ?? 0,
263
+ }
264
+ : undefined;
265
+ return text
266
+ ? { ok: true, text, usage }
267
+ : { ok: false, error: "provider returned empty content" };
268
+ });
227
269
  }
228
270
  catch (err) {
229
- return { ok: false, error: callError(err, timeoutMs) };
271
+ return {
272
+ ok: false,
273
+ error: safeProviderError(failureResponse
274
+ ? `provider HTTP ${failureResponse.status}: ${callError(err, timeoutMs)}`
275
+ : callError(err, timeoutMs), this.apiKey),
276
+ ...(failureResponse
277
+ ? {
278
+ status: failureResponse.status,
279
+ retryAfterMs: retryAfterMs(failureResponse),
280
+ }
281
+ : {}),
282
+ };
230
283
  }
231
284
  }
232
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
+ }
233
366
  // Build the provider from the spec's model block + env key. Throws a clear
234
367
  // error if no model is configured (self-host requires one) or the env key is
235
368
  // missing. fetch is injectable for tests.
@@ -262,7 +395,10 @@ export function selectProvider(spec, env, fetchFn = fetch) {
262
395
  if (!resolvedBase) {
263
396
  throw new Error("openai-compatible provider needs model.baseUrl");
264
397
  }
265
- return new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
398
+ const direct = new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
399
+ return provider === "nvidia"
400
+ ? new SameModelRetryProvider(direct, provider, name)
401
+ : direct;
266
402
  }
267
403
  // Build a provider for an EXPLICIT route + raw key (no spec, no env) — the
268
404
  // decision probe's entry point. Same classes as selectProvider, so a probe
@@ -36,6 +36,7 @@ const CONFIG_BLOCKS = [
36
36
  "venues",
37
37
  "risk",
38
38
  "sizing",
39
+ "capitalSizing",
39
40
  "limits",
40
41
  "abstention",
41
42
  "sync",
@@ -1,6 +1,7 @@
1
1
  import { CoinRithmClient, ProvenanceReport } from "./client.js";
2
2
  import { Provider } from "./providers.js";
3
3
  import { AgentSpec, RunState, CycleResult, Decision, ProposedAction, PmMarket, PostedOpportunity, QuoteEvidence } from "./types.js";
4
+ import { type DecisionInputRecord } from "./decisionReceipt.js";
4
5
  export interface RunnerDeps {
5
6
  client: CoinRithmClient;
6
7
  provider: Provider;
@@ -10,13 +11,15 @@ export interface RunnerDeps {
10
11
  live: boolean;
11
12
  stateFile?: string;
12
13
  log?: (line: string) => void;
14
+ /** Optional private storage hook. Its failure never changes cycle execution. */
15
+ onDecisionInputRecord?: (record: DecisionInputRecord) => void;
13
16
  }
14
17
  export declare function houseAgentForecastEnabled(): boolean;
15
18
  export declare function agentOpportunityCaptureEnabled(): boolean;
16
19
  export declare function runnerRuntimeKind(): ProvenanceReport["runtimeKind"];
17
20
  export declare function buildRunnerProvenance(spec: AgentSpec): ProvenanceReport;
18
21
  export declare function sanitizeForecastProbability(raw: unknown): number | undefined;
19
- export declare function repairFuturesTakeProfit(action: ProposedAction, quote?: QuoteEvidence): {
22
+ export declare function repairFuturesTakeProfit(action: ProposedAction, quote?: QuoteEvidence, capitalMinimumRewardRisk?: number): {
20
23
  action: ProposedAction;
21
24
  repaired: boolean;
22
25
  };