@coinrithm/mcp-trading 0.7.15 → 0.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +127 -8
  2. package/README.md +18 -3
  3. package/dist/agent/bench/bench.d.ts +59 -0
  4. package/dist/agent/bench/bench.js +319 -0
  5. package/dist/agent/bench/cassette.d.ts +97 -0
  6. package/dist/agent/bench/cassette.js +181 -0
  7. package/dist/agent/bench/costs.d.ts +28 -0
  8. package/dist/agent/bench/costs.js +60 -0
  9. package/dist/agent/bench/labelBuilder.d.ts +123 -0
  10. package/dist/agent/bench/labelBuilder.js +282 -0
  11. package/dist/agent/bench/labels.d.ts +75 -0
  12. package/dist/agent/bench/labels.js +259 -0
  13. package/dist/agent/bench/recordingClient.d.ts +51 -0
  14. package/dist/agent/bench/recordingClient.js +193 -0
  15. package/dist/agent/bench/replayClient.d.ts +41 -0
  16. package/dist/agent/bench/replayClient.js +262 -0
  17. package/dist/agent/bench/report.d.ts +115 -0
  18. package/dist/agent/bench/report.js +435 -0
  19. package/dist/agent/cli.d.ts +25 -0
  20. package/dist/agent/cli.js +300 -2
  21. package/dist/agent/client.d.ts +1 -0
  22. package/dist/agent/client.js +6 -0
  23. package/dist/agent/configurationWarnings.js +11 -0
  24. package/dist/agent/decision.d.ts +3 -0
  25. package/dist/agent/decision.js +85 -3
  26. package/dist/agent/decisionValidator.d.ts +30 -0
  27. package/dist/agent/decisionValidator.js +147 -2
  28. package/dist/agent/engine.d.ts +3 -3
  29. package/dist/agent/engine.js +3 -3
  30. package/dist/agent/observe.d.ts +27 -2
  31. package/dist/agent/observe.js +529 -89
  32. package/dist/agent/prompt.d.ts +4 -1
  33. package/dist/agent/prompt.js +98 -7
  34. package/dist/agent/promptTables.d.ts +1 -0
  35. package/dist/agent/promptTables.js +33 -0
  36. package/dist/agent/providerCapabilities.d.ts +6 -0
  37. package/dist/agent/providerCapabilities.js +23 -0
  38. package/dist/agent/providers.d.ts +25 -7
  39. package/dist/agent/providers.js +188 -48
  40. package/dist/agent/resolve.js +3 -0
  41. package/dist/agent/runner.d.ts +8 -0
  42. package/dist/agent/runner.js +59 -15
  43. package/dist/agent/setups.d.ts +2 -1
  44. package/dist/agent/setups.js +17 -16
  45. package/dist/agent/signals.d.ts +33 -0
  46. package/dist/agent/signals.js +89 -0
  47. package/dist/agent/skill.js +37 -0
  48. package/dist/agent/skillValidator.js +33 -0
  49. package/dist/agent/strictLint.js +29 -0
  50. package/dist/agent/types.d.ts +149 -12
  51. package/dist/agent/universe.d.ts +78 -0
  52. package/dist/agent/universe.js +215 -0
  53. package/package.json +3 -2
@@ -3,7 +3,64 @@
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
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
+ import { parseDecision } from "./decision.js";
7
+ import { chatShapeFor, buildChatBody, withJsonContentTransport, canRetrySuperToolOutput, DECISION_TOOL_NAME, NVIDIA_BASE_URL as CAP_NVIDIA_BASE_URL, } from "./providerCapabilities.js";
8
+ // A token count as Anthropic reports it: a non-negative safe integer. Zero
9
+ // is a real count; anything else (missing, null, negative, NaN, fractional,
10
+ // a string) is malformed.
11
+ function exactCount(value) {
12
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
13
+ ? value
14
+ : undefined;
15
+ }
16
+ // Anthropic usage, including prompt-cache reads and writes (and the 5m/1h
17
+ // write split) when reported. Strict, because paid metering prices it (root
18
+ // review of #118): input_tokens and output_tokens are mandatory, and a
19
+ // malformed reported field (mandatory or cache) makes the WHOLE usage unknown
20
+ // (undefined), never zero, so the call is metered uncertain instead of free.
21
+ // Cache fields the response does not carry at all stay absent. A reported
22
+ // write total must equal its 5m/1h split; a split without a total implies it.
23
+ export function anthropicUsage(raw) {
24
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
25
+ return undefined;
26
+ const u = raw;
27
+ const promptTokens = exactCount(u.input_tokens);
28
+ const completionTokens = exactCount(u.output_tokens);
29
+ if (promptTokens === undefined || completionTokens === undefined)
30
+ return undefined;
31
+ const usage = { promptTokens, completionTokens };
32
+ const optional = (value) => value === undefined ? undefined : (exactCount(value) ?? null);
33
+ const cacheRead = optional(u.cache_read_input_tokens);
34
+ const cacheWrite = optional(u.cache_creation_input_tokens);
35
+ let write5m;
36
+ let write1h;
37
+ if (u.cache_creation !== undefined) {
38
+ const split = u.cache_creation;
39
+ if (typeof split !== "object" || split === null || Array.isArray(split))
40
+ return undefined;
41
+ const s = split;
42
+ write5m = optional(s.ephemeral_5m_input_tokens);
43
+ write1h = optional(s.ephemeral_1h_input_tokens);
44
+ }
45
+ if ([cacheRead, cacheWrite, write5m, write1h].includes(null))
46
+ return undefined;
47
+ if (write5m != null || write1h != null) {
48
+ const splitTotal = (write5m ?? 0) + (write1h ?? 0);
49
+ if (cacheWrite != null && cacheWrite !== splitTotal)
50
+ return undefined;
51
+ usage.cacheWriteTokens = splitTotal;
52
+ if (write5m != null)
53
+ usage.cacheWrite5mTokens = write5m;
54
+ if (write1h != null)
55
+ usage.cacheWrite1hTokens = write1h;
56
+ }
57
+ else if (cacheWrite != null) {
58
+ usage.cacheWriteTokens = cacheWrite;
59
+ }
60
+ if (cacheRead != null)
61
+ usage.cacheReadTokens = cacheRead;
62
+ return usage;
63
+ }
7
64
  // NVIDIA NIM is OpenAI-compatible; the `nvidia` preset hard-wires the hosted
8
65
  // endpoint so an agent only needs `{ provider: nvidia, name: "<model id>" }`.
9
66
  const NVIDIA_BASE_URL = CAP_NVIDIA_BASE_URL;
@@ -31,6 +88,8 @@ const DEFAULT_TIMEOUT_MS = 300_000;
31
88
  const RETRYABLE_SERVER_STATUSES = new Set([500, 502, 503, 504]);
32
89
  /** Keep direct and routed cycle evidence consistent about NVIDIA backpressure. */
33
90
  export function classifyProviderFailure(result) {
91
+ if (result.failureClass === "malformed")
92
+ return "malformed";
34
93
  if (result.status === 429 ||
35
94
  (result.status === 503 &&
36
95
  /resourceexhausted|worker local total request limit/i.test(result.error ?? "")))
@@ -186,15 +245,27 @@ class AnthropicProvider {
186
245
  }
187
246
  const json = (await res.json());
188
247
  const text = json.content?.map((c) => c.text ?? "").join("") ?? "";
189
- const usage = json.usage
190
- ? {
191
- promptTokens: json.usage.input_tokens ?? 0,
192
- completionTokens: json.usage.output_tokens ?? 0,
193
- }
194
- : undefined;
248
+ // Absent or malformed usage is undefined (unknown), never zero.
249
+ const usage = anthropicUsage(json.usage);
250
+ if (json.stop_reason === "max_tokens" ||
251
+ json.stop_reason === "model_context_window_exceeded") {
252
+ return {
253
+ ok: false,
254
+ failureClass: "malformed",
255
+ error: json.stop_reason === "max_tokens"
256
+ ? "provider returned incomplete decision (output token limit)"
257
+ : "provider returned incomplete decision (context window limit)",
258
+ usage,
259
+ };
260
+ }
195
261
  return text
196
262
  ? { ok: true, text, usage, responseSource: "content" }
197
- : { ok: false, error: "anthropic returned empty content" };
263
+ : // Answered (and billed) with nothing usable: keep the usage.
264
+ {
265
+ ok: false,
266
+ error: "anthropic returned empty content",
267
+ ...(usage ? { usage } : {}),
268
+ };
198
269
  });
199
270
  }
200
271
  catch (err) {
@@ -213,24 +284,40 @@ class AnthropicProvider {
213
284
  }
214
285
  }
215
286
  }
287
+ function wantsJsonContent(options) {
288
+ const v = options.nemotronJsonContent;
289
+ if (typeof v !== "function")
290
+ return v === true;
291
+ try {
292
+ return v() === true;
293
+ }
294
+ catch {
295
+ return false;
296
+ }
297
+ }
216
298
  class OpenAiCompatProvider {
217
299
  provider;
218
300
  model;
219
301
  apiKey;
220
302
  baseUrl;
221
303
  fetchFn;
304
+ options;
222
305
  label;
223
- constructor(provider, model, apiKey, baseUrl, fetchFn) {
306
+ constructor(provider, model, apiKey, baseUrl, fetchFn, options = {}) {
224
307
  this.provider = provider;
225
308
  this.model = model;
226
309
  this.apiKey = apiKey;
227
310
  this.baseUrl = baseUrl;
228
311
  this.fetchFn = fetchFn;
312
+ this.options = options;
229
313
  this.label = `${baseUrl}/${model}`;
230
314
  }
231
315
  async decide(input) {
232
316
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
233
- const shape = chatShapeFor(this.provider, this.model, this.baseUrl);
317
+ const baseShape = chatShapeFor(this.provider, this.model, this.baseUrl);
318
+ const shape = wantsJsonContent(this.options)
319
+ ? withJsonContentTransport(baseShape)
320
+ : baseShape;
234
321
  let failureResponse;
235
322
  try {
236
323
  return await withProviderTimeout(timeoutMs, async (signal) => {
@@ -273,6 +360,14 @@ class OpenAiCompatProvider {
273
360
  completionTokens: json.usage.completion_tokens ?? 0,
274
361
  }
275
362
  : undefined;
363
+ if (json.choices?.[0]?.finish_reason === "length") {
364
+ return {
365
+ ok: false,
366
+ failureClass: "malformed",
367
+ error: "provider returned incomplete decision (output token limit)",
368
+ usage,
369
+ };
370
+ }
276
371
  return text
277
372
  ? {
278
373
  ok: true,
@@ -303,20 +398,24 @@ class OpenAiCompatProvider {
303
398
  }
304
399
  }
305
400
  }
306
- // A direct NVIDIA 5xx previously lost the whole cycle until the next cadence.
307
- // Retry only an explicit server refusal, once, on the identical configured
308
- // route. The shared router owns its own attempt/capacity budget and MUST NOT
309
- // receive this wrapper. No auth/404/429, network, timeout or decision repair
310
- // retries; no model substitution or request-parameter changes.
401
+ // Direct NVIDIA recovery has one shared two-call/deadline budget. Explicit
402
+ // server refusals retry the original request; rejected Super tool encoding
403
+ // retries JSON content on the same configured route. The shared router owns
404
+ // its own admission budget and MUST NOT receive this wrapper. No model
405
+ // substitution or repair/coercion of a rejected decision is allowed.
311
406
  class SameModelRetryProvider {
312
407
  delegate;
313
408
  provider;
314
409
  model;
410
+ contentRetry;
411
+ baseUrl;
315
412
  label;
316
- constructor(delegate, provider, model) {
413
+ constructor(delegate, provider, model, contentRetry, baseUrl) {
317
414
  this.delegate = delegate;
318
415
  this.provider = provider;
319
416
  this.model = model;
417
+ this.contentRetry = contentRetry;
418
+ this.baseUrl = baseUrl;
320
419
  this.label = delegate.label;
321
420
  }
322
421
  async decide(input) {
@@ -324,56 +423,95 @@ class SameModelRetryProvider {
324
423
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
325
424
  const first = await this.delegate.decide(input);
326
425
  const firstFinished = Date.now();
327
- if (first.ok || !first.retryableHttpFailure) {
426
+ const parsedFirst = first.ok ? parseDecision(first.text) : undefined;
427
+ const retryContent = first.ok &&
428
+ parsedFirst?.ok === false &&
429
+ this.contentRetry !== undefined &&
430
+ canRetrySuperToolOutput({ provider: this.provider, model: this.model, baseUrl: this.baseUrl }, first.responseSource, parsedFirst.actionsStringDiagnostic);
431
+ if (!retryContent && (first.ok || !first.retryableHttpFailure)) {
328
432
  return first;
329
433
  }
330
- const delayMs = Math.max(1000, first.retryAfterMs ?? 0);
434
+ const delayMs = retryContent
435
+ ? 0
436
+ : Math.max(1000, first.ok ? 0 : (first.retryAfterMs ?? 0));
331
437
  // Honor the provider's cooldown; a long one waits for the next cycle. Both
332
438
  // attempts and this backoff share the ORIGINAL deadline, never two 5m caps.
333
439
  if (delayMs > 5000 || delayMs >= timeoutMs - (firstFinished - started)) {
334
440
  return first;
335
441
  }
336
- await new Promise((resolve) => setTimeout(resolve, delayMs));
442
+ if (delayMs > 0)
443
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
337
444
  const retryStarted = Date.now();
338
445
  const remainingMs = timeoutMs - (retryStarted - started);
339
446
  if (remainingMs <= 0)
340
447
  return first;
341
- const result = await this.delegate.decide({
448
+ const retryResult = await (retryContent ? this.contentRetry : this.delegate).decide({
342
449
  ...input,
343
450
  timeoutMs: remainingMs,
344
451
  });
345
- const attempt = (res, latencyMs) => ({
346
- provider: this.provider,
347
- model: this.model,
348
- outcome: res.ok ? "success" : "failed",
349
- latencyMs,
350
- ...(!res.ok
351
- ? {
352
- error: res.error,
353
- status: res.status,
354
- retryAfterMs: res.retryAfterMs,
355
- failureClass: classifyProviderFailure(res) === "capacity"
356
- ? "capacity"
357
- : res.status !== undefined && res.status < 500
358
- ? "permanent"
359
- : "transient",
360
- }
361
- : {}),
362
- });
452
+ const parsedRetry = retryResult.ok
453
+ ? parseDecision(retryResult.text)
454
+ : undefined;
455
+ const result = retryResult.ok && parsedRetry?.ok === false
456
+ ? {
457
+ ok: false,
458
+ failureClass: "malformed",
459
+ error: parsedRetry.error,
460
+ usage: retryResult.usage,
461
+ }
462
+ : retryResult;
463
+ const attempt = (res, latencyMs) => {
464
+ const parsed = res.ok ? parseDecision(res.text) : undefined;
465
+ return {
466
+ provider: this.provider,
467
+ model: this.model,
468
+ outcome: res.ok && parsed?.ok ? "success" : "failed",
469
+ latencyMs,
470
+ ...(res.ok ? { responseSource: res.responseSource } : {}),
471
+ ...(parsed?.ok === false
472
+ ? {
473
+ failureClass: "malformed",
474
+ error: parsed.error,
475
+ actionsStringDiagnostic: parsed.actionsStringDiagnostic,
476
+ }
477
+ : {}),
478
+ ...(!res.ok
479
+ ? {
480
+ error: res.error,
481
+ status: res.status,
482
+ retryAfterMs: res.retryAfterMs,
483
+ failureClass: res.failureClass ??
484
+ (classifyProviderFailure(res) === "capacity"
485
+ ? "capacity"
486
+ : res.status !== undefined && res.status < 500
487
+ ? "permanent"
488
+ : "transient"),
489
+ }
490
+ : {}),
491
+ };
492
+ };
493
+ // Sum reported usage from both physical calls, including rejected output.
494
+ // Missing usage is unknown, not evidence that a request consumed no tokens.
495
+ const usage = first.usage || result.usage
496
+ ? {
497
+ promptTokens: (first.usage?.promptTokens ?? 0) +
498
+ (result.usage?.promptTokens ?? 0),
499
+ completionTokens: (first.usage?.completionTokens ?? 0) +
500
+ (result.usage?.completionTokens ?? 0),
501
+ }
502
+ : undefined;
363
503
  return {
364
- // Usage, when present, describes only the final response. The preceding
365
- // 5xx did not report usage; attempt evidence is not total billed tokens
366
- // or a certificate that returned text passed the trading-decision parser.
367
504
  ...result,
505
+ ...(usage ? { usage } : {}),
368
506
  route: {
369
- policyVersion: "coinrithm.configured-same-model-retry.v2",
507
+ policyVersion: "coinrithm.configured-same-model-retry.v3",
370
508
  profile: "configured",
371
509
  effectiveProvider: this.provider,
372
510
  effectiveModel: this.model,
373
511
  reason: "configured_direct",
374
512
  attempts: [
375
513
  attempt(first, firstFinished - started),
376
- attempt(result, Date.now() - retryStarted),
514
+ attempt(retryResult, Date.now() - retryStarted),
377
515
  ],
378
516
  },
379
517
  };
@@ -382,7 +520,7 @@ class SameModelRetryProvider {
382
520
  // Build the provider from the spec's model block + env key. Throws a clear
383
521
  // error if no model is configured (self-host requires one) or the env key is
384
522
  // missing. fetch is injectable for tests.
385
- export function selectProvider(spec, env, fetchFn = fetch) {
523
+ export function selectProvider(spec, env, fetchFn = fetch, options = {}) {
386
524
  if (!spec.model) {
387
525
  throw new Error("no model configured: set model.provider + model.name in the agent (self-host needs an explicit model)");
388
526
  }
@@ -411,15 +549,17 @@ export function selectProvider(spec, env, fetchFn = fetch) {
411
549
  if (!resolvedBase) {
412
550
  throw new Error("openai-compatible provider needs model.baseUrl");
413
551
  }
414
- const direct = new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn);
552
+ const direct = new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn, options);
415
553
  return provider === "nvidia"
416
- ? new SameModelRetryProvider(direct, provider, name)
554
+ ? new SameModelRetryProvider(direct, provider, name, new OpenAiCompatProvider(provider, name, key, resolvedBase, fetchFn, {
555
+ nemotronJsonContent: true,
556
+ }), baseUrl)
417
557
  : direct;
418
558
  }
419
559
  // Build a provider for an EXPLICIT route + raw key (no spec, no env) — the
420
560
  // decision probe's entry point. Same classes as selectProvider, so a probe
421
561
  // exercises byte-identical request shapes to a real cycle.
422
- export function providerForRoute(route, apiKey, fetchFn = fetch) {
562
+ export function providerForRoute(route, apiKey, fetchFn = fetch, options = {}) {
423
563
  if (route.provider === "mechanical")
424
564
  return new MechanicalProvider(route.model);
425
565
  if (route.provider === "anthropic")
@@ -428,5 +568,5 @@ export function providerForRoute(route, apiKey, fetchFn = fetch) {
428
568
  if (!resolvedBase) {
429
569
  throw new Error("openai-compatible route needs a baseUrl");
430
570
  }
431
- return new OpenAiCompatProvider(route.provider, route.model, apiKey, resolvedBase, fetchFn);
571
+ return new OpenAiCompatProvider(route.provider, route.model, apiKey, resolvedBase, fetchFn, options);
432
572
  }
@@ -43,6 +43,9 @@ const CONFIG_BLOCKS = [
43
43
  "killSwitch",
44
44
  "objective",
45
45
  "capabilities",
46
+ "signals",
47
+ "data",
48
+ "universe",
46
49
  ];
47
50
  const JOURNAL_MAX_LINES = 200;
48
51
  const JOURNAL_MAX_BYTES = 8_000;
@@ -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 ObserveOptions } from "./observe.js";
4
5
  import { type DecisionInputRecord } from "./decisionReceipt.js";
5
6
  export interface RunnerDeps {
6
7
  client: CoinRithmClient;
@@ -13,6 +14,13 @@ export interface RunnerDeps {
13
14
  log?: (line: string) => void;
14
15
  /** Optional private storage hook. Its failure never changes cycle execution. */
15
16
  onDecisionInputRecord?: (record: DecisionInputRecord) => void;
17
+ /** Hosted shared-pool admission only; protective checks still run every cycle. */
18
+ minModelIntervalSeconds?: number;
19
+ /** Opt-in hosted canary; changes presentation only, preserving all values. */
20
+ compactPromptTables?: boolean;
21
+ /** Bench replay only (bench.ts): observation overrides. Live runs never
22
+ * set it. */
23
+ observeOptions?: ObserveOptions;
16
24
  }
17
25
  export declare function houseAgentForecastEnabled(): boolean;
18
26
  export declare function agentOpportunityCaptureEnabled(): boolean;
@@ -2,6 +2,7 @@
2
2
  // spot, futures, and prediction markets. Dry-run never writes. Live uses
3
3
  // idempotency keys + agentTrace and exports run evidence. The client + provider
4
4
  // are injected so the loop is fully unit-testable with no network/model calls.
5
+ import { signalThresholdsOf } from "./signals.js";
5
6
  import { classifyProviderFailure } from "./providers.js";
6
7
  import { COINRITHM_API } from "./version.js";
7
8
  import { spotBuyCost, DEFAULT_TRIGGER_POLICY, } from "./types.js";
@@ -15,7 +16,7 @@ import { createOpportunityReporter } from "./opportunityReporter.js";
15
16
  import { enrichWhaleContext, observe } from "./observe.js";
16
17
  import { buildDailyRiskBudget, buildFuturesCapacity, buildSystemPrompt, buildUserPrompt, } from "./prompt.js";
17
18
  import { parseDecision } from "./decision.js";
18
- import { validateAction, isRiskIncreasingAction, } from "./decisionValidator.js";
19
+ import { validateAction, isRiskIncreasingAction, pmEventKey, pmCoinHorizonOf, pmReferenceMs, } from "./decisionValidator.js";
19
20
  import { resolvePmRef } from "./resolvePm.js";
20
21
  import { fetchQuote, executeAction } from "./act.js";
21
22
  import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
@@ -482,7 +483,7 @@ async function runCycleCore(deps, capture) {
482
483
  log,
483
484
  });
484
485
  // OBSERVE
485
- const obs = await observe(client, spec, state, baseTrace);
486
+ const obs = await observe(client, spec, state, baseTrace, deps.observeOptions);
486
487
  const observation = obs.observation;
487
488
  const nowMs = Date.now();
488
489
  // Slice 2: attach to each open position the thesis it was opened on (from
@@ -632,7 +633,7 @@ async function runCycleCore(deps, capture) {
632
633
  // receipt over the observation the model actually decides on. The exit
633
634
  // traces above keep the pre-exit receipt they were decided on.
634
635
  observation.openPositions = observation.openPositions.filter((p) => !closedByThesis.includes(p.id));
635
- observation.setups = scanSetups(observation.watch, observation.openPositions);
636
+ observation.setups = scanSetups(observation.watch, observation.openPositions, signalThresholdsOf(spec));
636
637
  observationReceipt = buildObservationReceipt(observation);
637
638
  Object.assign(baseTrace, observationReceipt);
638
639
  }
@@ -642,6 +643,17 @@ async function runCycleCore(deps, capture) {
642
643
  // so it touches NO kill-switch counter; it just records the cheap cycle.
643
644
  const policy = spec.triggerPolicy ?? DEFAULT_TRIGGER_POLICY;
644
645
  const gate = evaluateGate(observation, state, policy, nowMs);
646
+ const minimumMs = Number.isFinite(deps.minModelIntervalSeconds)
647
+ ? Math.max(0, deps.minModelIntervalSeconds ?? 0) * 1000
648
+ : 0;
649
+ if (gate.fire &&
650
+ spec.model?.provider !== "mechanical" &&
651
+ minimumMs > 0 &&
652
+ state.lastLlmCallAt != null &&
653
+ nowMs - state.lastLlmCallAt < minimumMs) {
654
+ gate.fire = false;
655
+ gate.reason = `shared pool model interval (${Math.ceil(minimumMs / 1000)}s minimum)`;
656
+ }
645
657
  const providerName = spec.model?.provider ?? "nvidia";
646
658
  if (!gate.fire) {
647
659
  capture({
@@ -664,8 +676,8 @@ async function runCycleCore(deps, capture) {
664
676
  tokensIn: 0,
665
677
  tokensOut: 0,
666
678
  estimatedCostUsd: 0,
667
- writeAttempted: 0,
668
- writeAccepted: 0,
679
+ writeAttempted: live ? exitPlanned.length : 0,
680
+ writeAccepted: exitPlanned.filter((p) => p.executed).length,
669
681
  ...observationReceipt,
670
682
  };
671
683
  }
@@ -725,13 +737,20 @@ async function runCycleCore(deps, capture) {
725
737
  includeForecast: forecastEnabled,
726
738
  ...actionAvailability,
727
739
  });
728
- const user = buildUserPrompt(observation, state.journal, {
740
+ const userOptions = {
729
741
  venues: spec.venues,
730
742
  dailyRiskBudget: buildDailyRiskBudget(spec, state),
731
743
  ...(usesCapitalSizing(spec) ? { capitalSizing: spec.capitalSizing } : {}),
732
744
  ...(futuresCapacity ? { futuresCapacity } : {}),
733
745
  ...actionAvailability,
734
- });
746
+ };
747
+ const user = buildUserPrompt(observation, state.journal, userOptions);
748
+ const compactUser = deps.compactPromptTables
749
+ ? buildUserPrompt(observation, state.journal, {
750
+ ...userOptions,
751
+ compactTables: true,
752
+ })
753
+ : undefined;
735
754
  const tokensInEst = Math.round((system.length + user.length) / 4);
736
755
  // Prompt-size + trigger visibility in the live terminal.
737
756
  log(`prompt ~${tokensInEst} tok ` +
@@ -745,6 +764,7 @@ async function runCycleCore(deps, capture) {
745
764
  const res = await provider.decide({
746
765
  system,
747
766
  user,
767
+ ...(compactUser && compactUser !== user ? { compactUser } : {}),
748
768
  ...actionAvailability,
749
769
  });
750
770
  const route = res.route;
@@ -761,12 +781,11 @@ async function runCycleCore(deps, capture) {
761
781
  // Metering: prefer provider-reported usage; fall back to a chars/4 estimate.
762
782
  const tokensIn = !actualCallMade
763
783
  ? 0
764
- : res.ok
765
- ? (res.usage?.promptTokens ?? tokensInEst)
766
- : tokensInEst;
767
- const tokensOut = res.ok
768
- ? (res.usage?.completionTokens ?? Math.round(res.text.length / 4))
769
- : 0;
784
+ : (res.usage?.promptTokens ?? tokensInEst);
785
+ const tokensOut = !actualCallMade
786
+ ? 0
787
+ : (res.usage?.completionTokens ??
788
+ (res.ok ? Math.round(res.text.length / 4) : 0));
770
789
  const estimatedCostUsd = estimateCostUsd(effectiveProvider ?? providerName, tokensIn, tokensOut);
771
790
  meter = {
772
791
  triggerCodes: gate.codes,
@@ -782,6 +801,8 @@ async function runCycleCore(deps, capture) {
782
801
  // hosted shared provider use this same path. Attribute only actual calls.
783
802
  routeReason: route?.reason ?? (actualCallMade ? "configured_direct" : undefined),
784
803
  routeAttempts: route?.attempts,
804
+ // Provider-reported usage only, never the estimate above.
805
+ ...(actualCallMade && res.usage ? { providerUsage: res.usage } : {}),
785
806
  };
786
807
  if (!res.ok) {
787
808
  // Upstream 429s are expected provider backpressure, not evidence that the
@@ -800,7 +821,9 @@ async function runCycleCore(deps, capture) {
800
821
  decision: "skip",
801
822
  skipReason: actualCallMade
802
823
  ? "provider rate-limited; retry next cycle"
803
- : "provider capacity deferred",
824
+ : res.error === "shared pool owner budget unavailable"
825
+ ? "shared pool owner budget deferred"
826
+ : "provider capacity deferred",
804
827
  planned: exitPlanned,
805
828
  modelFailed: false,
806
829
  live,
@@ -811,7 +834,6 @@ async function runCycleCore(deps, capture) {
811
834
  ...observationReceipt,
812
835
  };
813
836
  }
814
- state.consecutiveModelFailures += 1;
815
837
  // Permanent-failure classification: a 404/410/model_not_found is a
816
838
  // DECOMMISSIONED or misconfigured model that will fail every cycle
817
839
  // until something changes (live-measured 2026-08-26: NVIDIA EOL'd the
@@ -874,6 +896,12 @@ async function runCycleCore(deps, capture) {
874
896
  }
875
897
  }
876
898
  else {
899
+ // Availability failures have their own route-specific hold counter.
900
+ // Charging them to the generic kill-switch as well disables BYO and
901
+ // self-hosted agents that keep retrying outside the shared circuit.
902
+ // Preserve prior transient/invalid-output failures until a valid
903
+ // decision clears them; a provider hold is not evidence of recovery.
904
+ state.consecutiveModelFailures += 1;
877
905
  state.consecutivePermanentModelErrors = 0;
878
906
  delete state.permanentModelErrorRoute;
879
907
  }
@@ -997,6 +1025,8 @@ async function runCycleCore(deps, capture) {
997
1025
  const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
998
1026
  const targetedPositionIds = [];
999
1027
  const targetedOrderIds = [];
1028
+ const pmEventsOpenedThisCycle = [];
1029
+ const pmCoinHorizonsOpenedThisCycle = [];
1000
1030
  let anyAccepted = false;
1001
1031
  let anyExecuted = false;
1002
1032
  let anyExecFailed = false;
@@ -1197,6 +1227,9 @@ async function runCycleCore(deps, capture) {
1197
1227
  realizedLossTodayMusd,
1198
1228
  targetedPositionIds,
1199
1229
  targetedOrderIds,
1230
+ pmEventsOpenedThisCycle,
1231
+ pmCoinHorizonsOpenedThisCycle,
1232
+ nowMs: Date.now(),
1200
1233
  };
1201
1234
  const v = validateAction(action, ctx);
1202
1235
  if (!v.valid) {
@@ -1233,6 +1266,17 @@ async function runCycleCore(deps, capture) {
1233
1266
  if (action.type === "spot_cancel") {
1234
1267
  targetedOrderIds.push(action.orderId);
1235
1268
  }
1269
+ if (action.type === "pm_open") {
1270
+ pmEventsOpenedThisCycle.push(pmEventKey(action.source, action.slug));
1271
+ const opened = observation.pmMarkets.find((m) => m.source === action.source.toLowerCase() &&
1272
+ m.slug === action.slug.toLowerCase() &&
1273
+ m.outcomeExternalMarketId === action.outcomeExternalMarketId);
1274
+ const horizon = opened
1275
+ ? pmCoinHorizonOf(opened, pmReferenceMs(observation.asOf, ctx.nowMs))
1276
+ : null;
1277
+ if (horizon)
1278
+ pmCoinHorizonsOpenedThisCycle.push(horizon);
1279
+ }
1236
1280
  if (!live) {
1237
1281
  planned.push({
1238
1282
  action,
@@ -1,3 +1,4 @@
1
+ import { type SignalThresholds } from "./signals.js";
1
2
  import { OpenPosition, SetupSignal, WatchEntry } from "./types.js";
2
3
  export declare function baseSymbol(s: string | undefined): string;
3
- export declare function scanSetups(watch: WatchEntry[], openPositions?: OpenPosition[]): SetupSignal[];
4
+ export declare function scanSetups(watch: WatchEntry[], openPositions?: OpenPosition[], thresholds?: SignalThresholds): SetupSignal[];