@coinrithm/mcp-trading 0.3.0 → 0.5.0

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 (41) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +36 -9
  3. package/dist/agent/act.js +8 -1
  4. package/dist/agent/cli.d.ts +1 -0
  5. package/dist/agent/cli.js +58 -4
  6. package/dist/agent/client.d.ts +8 -1
  7. package/dist/agent/client.js +14 -2
  8. package/dist/agent/decision.d.ts +66 -63
  9. package/dist/agent/decision.js +95 -24
  10. package/dist/agent/decisionValidator.js +41 -1
  11. package/dist/agent/deploymentOverlay.d.ts +22 -0
  12. package/dist/agent/deploymentOverlay.js +55 -0
  13. package/dist/agent/gate.d.ts +9 -0
  14. package/dist/agent/gate.js +114 -0
  15. package/dist/agent/indicators.js +22 -7
  16. package/dist/agent/observe.js +201 -18
  17. package/dist/agent/prompt.d.ts +6 -2
  18. package/dist/agent/prompt.js +116 -26
  19. package/dist/agent/providers.d.ts +6 -0
  20. package/dist/agent/providers.js +89 -12
  21. package/dist/agent/resolve.js +28 -0
  22. package/dist/agent/resolvePm.d.ts +14 -0
  23. package/dist/agent/resolvePm.js +69 -0
  24. package/dist/agent/runner.d.ts +6 -1
  25. package/dist/agent/runner.js +312 -10
  26. package/dist/agent/scorecard.d.ts +24 -0
  27. package/dist/agent/scorecard.js +177 -0
  28. package/dist/agent/setups.d.ts +3 -0
  29. package/dist/agent/setups.js +133 -0
  30. package/dist/agent/skill.d.ts +1 -0
  31. package/dist/agent/skill.js +21 -3
  32. package/dist/agent/skillValidator.js +4 -2
  33. package/dist/agent/state.js +10 -2
  34. package/dist/agent/templates.js +8 -4
  35. package/dist/agent/types.d.ts +75 -2
  36. package/dist/agent/types.js +14 -1
  37. package/dist/agent/version.d.ts +2 -2
  38. package/dist/agent/version.js +12 -2
  39. package/dist/client.d.ts +2 -0
  40. package/dist/tools.js +28 -5
  41. package/package.json +1 -1
@@ -5,6 +5,53 @@
5
5
  // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
6
6
  // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
7
7
  const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
8
+ // Gemini exposes an OpenAI-compatible surface, so the `gemini` preset hard-wires
9
+ // its hosted endpoint — an agent only needs `{ provider: gemini, name: "gemini-2.0-flash" }`
10
+ // plus a GEMINI_API_KEY. The free tier (no credit card, generous Flash quota) makes
11
+ // it the easiest key a USER can bring for their OWN agent — and a per-user key means
12
+ // each agent draws on its own quota, which is how the fleet actually scales.
13
+ const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai";
14
+ // Cap any single model call so a hung provider can't block an agent forever — but
15
+ // generously. The scheduler runs cycles SEQUENTIALLY per agent (it locks the row
16
+ // while a cycle runs and schedules the next from COMPLETION, not from claim), so a
17
+ // slow call no longer overlaps the next cadence; it just delays it. That lets the
18
+ // cap be long enough for a large model (e.g. a 70B on a busy free key) to finish
19
+ // and ACT, instead of being killed mid-thought and counted as a failure. On a real
20
+ // timeout the call still aborts -> a model failure -> retried next cadence (and the
21
+ // disable threshold is now far more tolerant of the odd flaky cycle).
22
+ // 5 minutes. Sequential scheduling (the scheduler RUN-LOCKS an agent while a cycle
23
+ // runs and reschedules the NEXT from completion) means a slow call never overlaps
24
+ // the next cadence — it just delays it. So a generous timeout lets a busy free 70B
25
+ // finish and ACT instead of being killed mid-thought and counted as a failure
26
+ // (the recurring Leo/70B timeout). A real hang still aborts -> retried next cadence.
27
+ // MUST stay below the scheduler's RUN_LOCK_SECONDS and HEARTBEAT_STALE_MS.
28
+ const DEFAULT_TIMEOUT_MS = 300_000;
29
+ // Reasoning models (NVIDIA Nemotron) DEFAULT to emitting a long <think> chain:
30
+ // measured ~30-60s/call and a JSON-leak risk. The documented toggle is a
31
+ // "detailed thinking off" line in the system prompt, which drops them to
32
+ // instruct mode (measured ~3-4s, clean JSON). Apply it automatically for any
33
+ // nemotron model so a per-cadence decision never blows the cadence.
34
+ function applyReasoningToggle(model, system) {
35
+ return /nemotron/i.test(model) ? `detailed thinking off\n\n${system}` : system;
36
+ }
37
+ // fetch with a hard timeout via AbortController. A custom fetchFn (tests) that
38
+ // ignores `signal` still works — the timer just never fires for it.
39
+ async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
42
+ try {
43
+ return await fetchFn(url, { ...init, signal: controller.signal });
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ }
49
+ function callError(err, timeoutMs) {
50
+ if (err instanceof Error && err.name === "AbortError") {
51
+ return `model call timed out after ${timeoutMs}ms`;
52
+ }
53
+ return err instanceof Error ? err.message : String(err);
54
+ }
8
55
  function envKey(provider, env) {
9
56
  switch (provider) {
10
57
  case "anthropic":
@@ -15,6 +62,8 @@ function envKey(provider, env) {
15
62
  return env.GROQ_API_KEY;
16
63
  case "nvidia":
17
64
  return env.NVIDIA_API_KEY ?? env.MODEL_API_KEY;
65
+ case "gemini":
66
+ return env.GEMINI_API_KEY ?? env.MODEL_API_KEY;
18
67
  case "openai-compatible":
19
68
  return env.MODEL_API_KEY ?? env.OPENAI_API_KEY;
20
69
  }
@@ -27,6 +76,8 @@ function baseUrlFor(provider, configured) {
27
76
  return "https://api.groq.com/openai/v1";
28
77
  case "nvidia":
29
78
  return NVIDIA_BASE_URL;
79
+ case "gemini":
80
+ return GEMINI_BASE_URL;
30
81
  case "openai-compatible":
31
82
  return (configured ?? "").replace(/\/+$/, "");
32
83
  case "anthropic":
@@ -45,8 +96,9 @@ class AnthropicProvider {
45
96
  this.label = `anthropic/${model}`;
46
97
  }
47
98
  async decide(input) {
99
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
48
100
  try {
49
- const res = await this.fetchFn("https://api.anthropic.com/v1/messages", {
101
+ const res = await fetchWithTimeout(this.fetchFn, "https://api.anthropic.com/v1/messages", {
50
102
  method: "POST",
51
103
  headers: {
52
104
  "x-api-key": this.apiKey,
@@ -59,15 +111,26 @@ class AnthropicProvider {
59
111
  system: input.system,
60
112
  messages: [{ role: "user", content: input.user }],
61
113
  }),
62
- });
114
+ }, timeoutMs);
63
115
  if (!res.ok)
64
- return { ok: false, error: `anthropic HTTP ${res.status}: ${await res.text()}` };
116
+ return {
117
+ ok: false,
118
+ // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
119
+ // unbounded provider error page must not bloat the ledger row.
120
+ error: `anthropic HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
121
+ };
65
122
  const json = (await res.json());
66
123
  const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
67
- return text ? { ok: true, text } : { ok: false, error: "anthropic returned empty content" };
124
+ const usage = json.usage
125
+ ? {
126
+ promptTokens: json.usage.input_tokens ?? 0,
127
+ completionTokens: json.usage.output_tokens ?? 0,
128
+ }
129
+ : undefined;
130
+ return text ? { ok: true, text, usage } : { ok: false, error: "anthropic returned empty content" };
68
131
  }
69
132
  catch (err) {
70
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
133
+ return { ok: false, error: callError(err, timeoutMs) };
71
134
  }
72
135
  }
73
136
  }
@@ -85,8 +148,9 @@ class OpenAiCompatProvider {
85
148
  this.label = `${baseUrl}/${model}`;
86
149
  }
87
150
  async decide(input) {
151
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
88
152
  try {
89
- const res = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
153
+ const res = await fetchWithTimeout(this.fetchFn, `${this.baseUrl}/chat/completions`, {
90
154
  method: "POST",
91
155
  headers: {
92
156
  Authorization: `Bearer ${this.apiKey}`,
@@ -98,19 +162,30 @@ class OpenAiCompatProvider {
98
162
  max_tokens: input.maxTokens ?? 1024,
99
163
  response_format: { type: "json_object" },
100
164
  messages: [
101
- { role: "system", content: input.system },
165
+ { role: "system", content: applyReasoningToggle(this.model, input.system) },
102
166
  { role: "user", content: input.user },
103
167
  ],
104
168
  }),
105
- });
169
+ }, timeoutMs);
106
170
  if (!res.ok)
107
- return { ok: false, error: `provider HTTP ${res.status}: ${await res.text()}` };
171
+ return {
172
+ ok: false,
173
+ // Cap the upstream body: it lands in agent_cycles.skip_reason, so an
174
+ // unbounded provider error page must not bloat the ledger row.
175
+ error: `provider HTTP ${res.status}: ${(await res.text()).slice(0, 2000)}`,
176
+ };
108
177
  const json = (await res.json());
109
178
  const text = json.choices?.[0]?.message?.content ?? "";
110
- return text ? { ok: true, text } : { ok: false, error: "provider returned empty content" };
179
+ const usage = json.usage
180
+ ? {
181
+ promptTokens: json.usage.prompt_tokens ?? 0,
182
+ completionTokens: json.usage.completion_tokens ?? 0,
183
+ }
184
+ : undefined;
185
+ return text ? { ok: true, text, usage } : { ok: false, error: "provider returned empty content" };
111
186
  }
112
187
  catch (err) {
113
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
188
+ return { ok: false, error: callError(err, timeoutMs) };
114
189
  }
115
190
  }
116
191
  }
@@ -130,7 +205,9 @@ export function selectProvider(spec, env, fetchFn = fetch) {
130
205
  ? "GROQ_API_KEY"
131
206
  : provider === "nvidia"
132
207
  ? "NVIDIA_API_KEY"
133
- : "OPENAI_API_KEY / MODEL_API_KEY";
208
+ : provider === "gemini"
209
+ ? "GEMINI_API_KEY"
210
+ : "OPENAI_API_KEY / MODEL_API_KEY";
134
211
  throw new Error(`missing model API key: set ${varName} in the environment (never in an agent file)`);
135
212
  }
136
213
  if (provider === "anthropic")
@@ -48,6 +48,7 @@ const JOURNAL_MAX_LINES = 200;
48
48
  const JOURNAL_MAX_BYTES = 8_000;
49
49
  // Optional prose files (markdown the LLM reads), in assembly order.
50
50
  const PROSE_FILES = ["character/thesis.md", "character/persona.md"];
51
+ const FUNCTIONALITY_PIN = "functionality/coinrithm.yaml";
51
52
  // Enforced cap field names. sizing.yaml is SOFT guidance and must NOT contain
52
53
  // any of these (or a user could think a limit binds when it does not).
53
54
  const ENFORCED_FIELD_NAMES = new Set([
@@ -58,6 +59,7 @@ const ENFORCED_FIELD_NAMES = new Set([
58
59
  "maxConsecutiveModelFailures",
59
60
  "onRateLimitPressure",
60
61
  ]);
62
+ const SKILL_METADATA_KEYS = new Set(["type", "title", "description", "tags"]);
61
63
  // A $ref must be a LOCAL, RELATIVE path inside the agent folder — never a URL,
62
64
  // an absolute path, a home/drive path, or a Windows backslash path.
63
65
  function refSyntaxIssue(ref) {
@@ -391,6 +393,13 @@ function resolveDirectory(dir) {
391
393
  // a skill file may be pure prose (no frontmatter) — treat whole as body.
392
394
  body = readFileSync(abs, "utf8");
393
395
  }
396
+ for (const f of scanForSecrets(patch)) {
397
+ ctx.issues.push({
398
+ code: "secret_in_frontmatter",
399
+ path: refPath,
400
+ message: `${f} (skill frontmatter is committable metadata — remove secrets)`,
401
+ });
402
+ }
394
403
  includeOrder.push(name);
395
404
  skillProse.push({ source: refPath, text: body });
396
405
  applySkillPatch(ctx, rawFrontmatter, patch, refPath);
@@ -420,6 +429,23 @@ function resolveDirectory(dir) {
420
429
  });
421
430
  }
422
431
  }
432
+ // Optional API/tool contract pin. It is locked for reproducibility and stale
433
+ // warnings, but it is not part of AgentSpec and is never sent to the model.
434
+ const functionalityPath = join(dir, FUNCTIONALITY_PIN);
435
+ if (existsSync(functionalityPath)) {
436
+ const abs = safePath(ctx, FUNCTIONALITY_PIN, "functionality pin");
437
+ if (abs) {
438
+ const parsed = parseYamlSafe(ctx, readHashed(ctx, abs), FUNCTIONALITY_PIN);
439
+ for (const f of scanForSecrets(parsed)) {
440
+ ctx.issues.push({
441
+ code: "secret_in_functionality",
442
+ path: FUNCTIONALITY_PIN,
443
+ message: `${f} (the functionality pin is committable metadata — remove secrets)`,
444
+ });
445
+ }
446
+ sources.functionality = FUNCTIONALITY_PIN;
447
+ }
448
+ }
423
449
  const mergedProse = mergeProseParts(proseParts);
424
450
  checkSizing(ctx, rawFrontmatter);
425
451
  scanSecrets(ctx, rawFrontmatter, mergedProse);
@@ -444,6 +470,8 @@ function resolveDirectory(dir) {
444
470
  // permitted, tighten-only; anything else is rejected (no permission expansion).
445
471
  function applySkillPatch(ctx, rawFrontmatter, patch, sourceLabel) {
446
472
  for (const key of Object.keys(patch)) {
473
+ if (SKILL_METADATA_KEYS.has(key))
474
+ continue;
447
475
  if (key === "risk" || key === "limits") {
448
476
  const caps = key === "risk" ? RISK_CAPS : LIMIT_CAPS;
449
477
  const base = rawFrontmatter[key] ?? {};
@@ -0,0 +1,14 @@
1
+ import { PmMarket, ProposedAction } from "./types.js";
2
+ type PmOpen = Extract<ProposedAction, {
3
+ type: "pm_open";
4
+ }>;
5
+ export type PmRefResolution = {
6
+ ok: true;
7
+ action: PmOpen;
8
+ } | {
9
+ ok: false;
10
+ code: string;
11
+ reason: string;
12
+ };
13
+ export declare function resolvePmRef(action: PmOpen, pmMarkets: PmMarket[]): PmRefResolution;
14
+ export {};
@@ -0,0 +1,69 @@
1
+ // Resolve a short pm ref (pm1…pmN) on a pm_open action back to the canonical
2
+ // {source, slug, outcomeExternalMarketId} triple, using the cycle's discovered
3
+ // pmMarkets. This is the reliability fix for prediction-market trading: small
4
+ // free models (Llama 3.1 8B) reliably copy a 3-char ref but mis-copy the long
5
+ // outcomeExternalMarketId, which previously made every PM open fail closed with
6
+ // pm_market_not_discovered. The prompt now asks the model for a ref; the runner
7
+ // resolves it here BEFORE validation so the validator/act phase still see the
8
+ // full triple they require.
9
+ //
10
+ // Back-compat: a model that sends the full triple (no ref) passes through
11
+ // unchanged. Robustness: a model that drops the ref into one of the id fields
12
+ // (a common 8B mistake) is still detected and resolved.
13
+ const REF_RE = /^pm\d+$/i;
14
+ const trimLower = (v) => typeof v === "string" ? v.trim().toLowerCase() : "";
15
+ // Find a pmN-shaped token the model may have placed in `ref` or — for sloppy
16
+ // small models — in one of the id fields. Real Kalshi/Polymarket ids are long
17
+ // hex/uuid strings and never match /^pm\d+$/, so this can't false-positive on a
18
+ // genuine id.
19
+ function extractRef(action) {
20
+ const candidates = [
21
+ action.ref,
22
+ action.outcomeExternalMarketId,
23
+ action.slug,
24
+ action.source,
25
+ ];
26
+ for (const c of candidates) {
27
+ const t = trimLower(c);
28
+ if (REF_RE.test(t))
29
+ return t;
30
+ }
31
+ return undefined;
32
+ }
33
+ export function resolvePmRef(action, pmMarkets) {
34
+ const ref = extractRef(action);
35
+ if (ref) {
36
+ const mkt = pmMarkets.find((m) => trimLower(m.ref) === ref);
37
+ if (!mkt) {
38
+ const known = pmMarkets.length
39
+ ? `pm1..pm${pmMarkets.length}`
40
+ : "(none discovered this cycle)";
41
+ return {
42
+ ok: false,
43
+ code: "pm_ref_unknown",
44
+ reason: `ref ${ref} is not one of this cycle's listed PM markets ${known}`,
45
+ };
46
+ }
47
+ // Canonicalise: take the triple from the matched market, drop the ref.
48
+ return {
49
+ ok: true,
50
+ action: {
51
+ ...action,
52
+ ref: undefined,
53
+ source: mkt.source,
54
+ slug: mkt.slug,
55
+ outcomeExternalMarketId: mkt.outcomeExternalMarketId,
56
+ },
57
+ };
58
+ }
59
+ // No ref: require the full back-compat triple. Empty/missing → reject with a
60
+ // clear, model-actionable message rather than a downstream crash.
61
+ if (action.source && action.slug && action.outcomeExternalMarketId) {
62
+ return { ok: true, action };
63
+ }
64
+ return {
65
+ ok: false,
66
+ code: "pm_ref_missing",
67
+ reason: "pm_open needs a ref (pmN) copied from one observation.pmMarkets entry, or the full source+slug+outcomeExternalMarketId",
68
+ };
69
+ }
@@ -1,6 +1,6 @@
1
1
  import { CoinRithmClient } from "./client.js";
2
2
  import { Provider } from "./providers.js";
3
- import { AgentSpec, RunState, CycleResult } from "./types.js";
3
+ import { AgentSpec, RunState, CycleResult, ProposedAction, QuoteEvidence } from "./types.js";
4
4
  export interface RunnerDeps {
5
5
  client: CoinRithmClient;
6
6
  provider: Provider;
@@ -11,6 +11,11 @@ export interface RunnerDeps {
11
11
  stateFile?: string;
12
12
  log?: (line: string) => void;
13
13
  }
14
+ export declare function repairFuturesTakeProfit(action: ProposedAction, quote?: QuoteEvidence): {
15
+ action: ProposedAction;
16
+ repaired: boolean;
17
+ };
18
+ export declare function rationaleForAction(a: ProposedAction, decisionRationale: string | undefined, perActionSummary: string | undefined, totalActions: number): string | undefined;
14
19
  export declare function runCycle(deps: RunnerDeps): Promise<CycleResult>;
15
20
  export interface LoopOptions {
16
21
  once?: boolean;