@coinrithm/mcp-trading 0.1.8 → 0.3.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.
- package/CHANGELOG.md +32 -0
- package/README.md +43 -19
- package/dist/agent/act.d.ts +4 -0
- package/dist/agent/act.js +114 -0
- package/dist/agent/capabilityGuard.d.ts +2 -0
- package/dist/agent/capabilityGuard.js +131 -0
- package/dist/agent/cli.d.ts +21 -0
- package/dist/agent/cli.js +382 -0
- package/dist/agent/client.d.ts +107 -0
- package/dist/agent/client.js +173 -0
- package/dist/agent/decision.d.ts +137 -0
- package/dist/agent/decision.js +118 -0
- package/dist/agent/decisionValidator.d.ts +16 -0
- package/dist/agent/decisionValidator.js +215 -0
- package/dist/agent/engine.d.ts +10 -0
- package/dist/agent/engine.js +16 -0
- package/dist/agent/extract.d.ts +4 -0
- package/dist/agent/extract.js +5 -0
- package/dist/agent/frontmatter.d.ts +5 -0
- package/dist/agent/frontmatter.js +19 -0
- package/dist/agent/index.d.ts +2 -0
- package/dist/agent/index.js +10 -0
- package/dist/agent/indicators.d.ts +44 -0
- package/dist/agent/indicators.js +135 -0
- package/dist/agent/manifest.d.ts +15 -0
- package/dist/agent/manifest.js +40 -0
- package/dist/agent/mergeRules.d.ts +11 -0
- package/dist/agent/mergeRules.js +82 -0
- package/dist/agent/observe.d.ts +7 -0
- package/dist/agent/observe.js +244 -0
- package/dist/agent/prompt.d.ts +3 -0
- package/dist/agent/prompt.js +76 -0
- package/dist/agent/providers.d.ts +25 -0
- package/dist/agent/providers.js +143 -0
- package/dist/agent/resolve.d.ts +11 -0
- package/dist/agent/resolve.js +499 -0
- package/dist/agent/runEvidence.d.ts +6 -0
- package/dist/agent/runEvidence.js +23 -0
- package/dist/agent/runner.d.ts +19 -0
- package/dist/agent/runner.js +280 -0
- package/dist/agent/skill.d.ts +12 -0
- package/dist/agent/skill.js +136 -0
- package/dist/agent/skillValidator.d.ts +7 -0
- package/dist/agent/skillValidator.js +123 -0
- package/dist/agent/state.d.ts +7 -0
- package/dist/agent/state.js +96 -0
- package/dist/agent/strictLint.d.ts +3 -0
- package/dist/agent/strictLint.js +165 -0
- package/dist/agent/templates.d.ts +14 -0
- package/dist/agent/templates.js +192 -0
- package/dist/agent/types.d.ts +286 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/util.d.ts +13 -0
- package/dist/agent/util.js +116 -0
- package/dist/agent/version.d.ts +11 -0
- package/dist/agent/version.js +16 -0
- package/dist/client.d.ts +162 -0
- package/dist/http.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/tools.d.ts +3 -0
- package/dist/version.d.ts +1 -0
- package/package.json +78 -67
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { CoinRithmClient } from "./client.js";
|
|
2
|
+
import { Provider } from "./providers.js";
|
|
3
|
+
import { AgentSpec, RunState, CycleResult } from "./types.js";
|
|
4
|
+
export interface RunnerDeps {
|
|
5
|
+
client: CoinRithmClient;
|
|
6
|
+
provider: Provider;
|
|
7
|
+
spec: AgentSpec;
|
|
8
|
+
mergedProse: string;
|
|
9
|
+
state: RunState;
|
|
10
|
+
live: boolean;
|
|
11
|
+
stateFile?: string;
|
|
12
|
+
log?: (line: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function runCycle(deps: RunnerDeps): Promise<CycleResult>;
|
|
15
|
+
export interface LoopOptions {
|
|
16
|
+
once?: boolean;
|
|
17
|
+
maxCycles?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function runLoop(deps: RunnerDeps, opts?: LoopOptions): Promise<CycleResult[]>;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The execution loop: observe -> decide (BYO model) -> validate -> act across
|
|
2
|
+
// spot, futures, and prediction markets. Dry-run never writes. Live uses
|
|
3
|
+
// idempotency keys + agentTrace and exports run evidence. The client + provider
|
|
4
|
+
// are injected so the loop is fully unit-testable with no network/model calls.
|
|
5
|
+
import { spotBuyCost, } from "./types.js";
|
|
6
|
+
import { observe } from "./observe.js";
|
|
7
|
+
import { buildSystemPrompt, buildUserPrompt } from "./prompt.js";
|
|
8
|
+
import { parseDecision } from "./decision.js";
|
|
9
|
+
import { validateAction } from "./decisionValidator.js";
|
|
10
|
+
import { fetchQuote, executeAction } from "./act.js";
|
|
11
|
+
import { makeDecisionId, makeTrace, exportRunEvidence } from "./runEvidence.js";
|
|
12
|
+
import { rollDay, checkKillSwitch, accrueRealized, saveState, } from "./state.js";
|
|
13
|
+
import { asObj, asNum, asStr } from "./extract.js";
|
|
14
|
+
import { parseCadenceMs, sleep } from "./util.js";
|
|
15
|
+
// A stable idempotency-key component per distinct intent (so a lost response
|
|
16
|
+
// replays rather than re-trades, but a genuinely new intent gets a new key).
|
|
17
|
+
function intentKeyOf(action) {
|
|
18
|
+
if (action.type === "futures_open") {
|
|
19
|
+
return `open:${action.symbol.toUpperCase()}:${action.side}:${action.leverage}:${action.marginMusd}`;
|
|
20
|
+
}
|
|
21
|
+
if (action.type === "futures_close") {
|
|
22
|
+
return `close:${action.positionId}:${action.fraction ?? "full"}`;
|
|
23
|
+
}
|
|
24
|
+
if (action.type === "futures_set_sltp") {
|
|
25
|
+
return `sltp:${action.positionId}`;
|
|
26
|
+
}
|
|
27
|
+
if (action.type === "spot_order") {
|
|
28
|
+
return `spot:${action.symbol.toUpperCase()}:${action.side}:${action.orderType}:${action.quantity}:${action.limitPrice ?? ""}:${action.stopPrice ?? ""}`;
|
|
29
|
+
}
|
|
30
|
+
if (action.type === "spot_cancel") {
|
|
31
|
+
return `cancel:${action.orderId}`;
|
|
32
|
+
}
|
|
33
|
+
if (action.type === "pm_open") {
|
|
34
|
+
return `pm:${action.source.toLowerCase()}:${action.slug.toLowerCase()}:${action.outcomeExternalMarketId}:${action.stakeMusd}`;
|
|
35
|
+
}
|
|
36
|
+
return "other"; // unreachable: every action type is handled above
|
|
37
|
+
}
|
|
38
|
+
// Estimated cash a successful action consumes (for the running-cash guard):
|
|
39
|
+
// futures margin, spot buy notional, or a PM stake. Closes/cancels/sells free
|
|
40
|
+
// cash or are neutral, so they consume nothing here. Spot buys use the SAME
|
|
41
|
+
// `spotBuyCost` helper the validator gates on, so the gate and this decrement
|
|
42
|
+
// never diverge. The `?? 0` is unreachable for an EXECUTED buy: the validator
|
|
43
|
+
// fails closed (missing_quote_price) on any buy whose cost can't be sized, so
|
|
44
|
+
// nothing with an undefined cost ever reaches execution to be decremented.
|
|
45
|
+
function cashConsumed(action, quote) {
|
|
46
|
+
if (action.type === "futures_open")
|
|
47
|
+
return action.marginMusd;
|
|
48
|
+
if (action.type === "pm_open")
|
|
49
|
+
return action.stakeMusd;
|
|
50
|
+
if (action.type === "spot_order" && action.side === "buy") {
|
|
51
|
+
return spotBuyCost(action, quote) ?? 0;
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
export async function runCycle(deps) {
|
|
56
|
+
const { client, provider, spec, mergedProse, state, live, stateFile } = deps;
|
|
57
|
+
const log = deps.log ?? (() => { });
|
|
58
|
+
state.cyclesRun += 1;
|
|
59
|
+
rollDay(state);
|
|
60
|
+
// Kill-switch pre-check: a disabled agent never observes, decides, or acts.
|
|
61
|
+
const tripped = checkKillSwitch(spec, state);
|
|
62
|
+
if (tripped) {
|
|
63
|
+
state.disabled = true;
|
|
64
|
+
state.disabledReason = tripped;
|
|
65
|
+
saveState(stateFile, state);
|
|
66
|
+
log(`disabled: ${tripped}`);
|
|
67
|
+
return {
|
|
68
|
+
decision: "skip",
|
|
69
|
+
planned: [],
|
|
70
|
+
disabled: true,
|
|
71
|
+
disabledReason: tripped,
|
|
72
|
+
live,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const runId = state.runId;
|
|
76
|
+
const decisionId = makeDecisionId(state.cyclesRun);
|
|
77
|
+
const baseTrace = makeTrace(runId, decisionId, spec);
|
|
78
|
+
// OBSERVE
|
|
79
|
+
const obs = await observe(client, spec, state, baseTrace);
|
|
80
|
+
const observation = obs.observation;
|
|
81
|
+
accrueRealized(state, observation.newClosedTrades);
|
|
82
|
+
state.cursor = observation.syncCursor;
|
|
83
|
+
for (const t of observation.newClosedTrades) {
|
|
84
|
+
state.seen.push(`${asStr(asObj(t).venue) ?? "futures"}:${asNum(asObj(t).id) ?? String(asObj(t).id)}`);
|
|
85
|
+
}
|
|
86
|
+
state.seen = state.seen.slice(-500);
|
|
87
|
+
// Equity-aware drawdown: open mark-to-market losses trip the kill-switch too,
|
|
88
|
+
// not only realized losses.
|
|
89
|
+
const unrealized = observation.openPositions.reduce((s, p) => s + (p.unrealizedPnlMusd ?? 0), 0);
|
|
90
|
+
if (spec.killSwitch.maxDrawdownMusd > 0 &&
|
|
91
|
+
state.peakRealizedMusd - (state.realizedPnlMusd + unrealized) >=
|
|
92
|
+
spec.killSwitch.maxDrawdownMusd) {
|
|
93
|
+
state.disabled = true;
|
|
94
|
+
state.disabledReason = `equity drawdown >= ${spec.killSwitch.maxDrawdownMusd}`;
|
|
95
|
+
saveState(stateFile, state);
|
|
96
|
+
log(`disabled: ${state.disabledReason}`);
|
|
97
|
+
return {
|
|
98
|
+
decision: "skip",
|
|
99
|
+
planned: [],
|
|
100
|
+
disabled: true,
|
|
101
|
+
disabledReason: state.disabledReason,
|
|
102
|
+
live,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (obs.skip) {
|
|
106
|
+
state.consecutiveRejectCycles += 1;
|
|
107
|
+
saveState(stateFile, state);
|
|
108
|
+
log(`skip: ${obs.skip}`);
|
|
109
|
+
return { decision: "skip", skipReason: obs.skip, planned: [], live };
|
|
110
|
+
}
|
|
111
|
+
// DECIDE
|
|
112
|
+
const res = await provider.decide({
|
|
113
|
+
system: buildSystemPrompt(spec, mergedProse),
|
|
114
|
+
user: buildUserPrompt(observation),
|
|
115
|
+
});
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
state.consecutiveModelFailures += 1;
|
|
118
|
+
saveState(stateFile, state);
|
|
119
|
+
log(`model error: ${res.error}`);
|
|
120
|
+
return {
|
|
121
|
+
decision: "skip",
|
|
122
|
+
skipReason: `model error: ${res.error}`,
|
|
123
|
+
planned: [],
|
|
124
|
+
modelFailed: true,
|
|
125
|
+
live,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const parsed = parseDecision(res.text);
|
|
129
|
+
if (!parsed.ok) {
|
|
130
|
+
state.consecutiveModelFailures += 1;
|
|
131
|
+
saveState(stateFile, state);
|
|
132
|
+
log(`model output invalid: ${parsed.error}`);
|
|
133
|
+
return {
|
|
134
|
+
decision: "skip",
|
|
135
|
+
skipReason: `model output invalid: ${parsed.error}`,
|
|
136
|
+
planned: [],
|
|
137
|
+
modelFailed: true,
|
|
138
|
+
live,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
state.consecutiveModelFailures = 0;
|
|
142
|
+
const decision = parsed.decision;
|
|
143
|
+
if (decision.decision === "skip" || decision.actions.length === 0) {
|
|
144
|
+
state.consecutiveRejectCycles += 1;
|
|
145
|
+
saveState(stateFile, state);
|
|
146
|
+
log(`model chose skip${decision.reason ? `: ${decision.reason}` : ""}`);
|
|
147
|
+
return {
|
|
148
|
+
decision: "skip",
|
|
149
|
+
skipReason: decision.reason ?? "model chose skip",
|
|
150
|
+
planned: [],
|
|
151
|
+
live,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// VALIDATE (+ ACT when live). Quote evidence is fetched by the runner.
|
|
155
|
+
const planned = [];
|
|
156
|
+
let writesThisCycle = 0;
|
|
157
|
+
let openCount = observation.openPositions.length;
|
|
158
|
+
// RUNNING totals so multiple opens in one cycle accumulate correctly.
|
|
159
|
+
let openMarginMusd = observation.openPositions
|
|
160
|
+
.filter((p) => p.venue === "futures")
|
|
161
|
+
.reduce((s, p) => s + (p.marginMusd ?? 0), 0);
|
|
162
|
+
let cashAvailableMusd = observation.cashAvailableMusd;
|
|
163
|
+
const realizedLossTodayMusd = Math.max(0, -state.realizedPnlTodayMusd);
|
|
164
|
+
const targetedPositionIds = [];
|
|
165
|
+
const targetedOrderIds = [];
|
|
166
|
+
let anyAccepted = false;
|
|
167
|
+
let anyExecuted = false;
|
|
168
|
+
let anyExecFailed = false;
|
|
169
|
+
for (const action of decision.actions) {
|
|
170
|
+
const quote = await fetchQuote(client, action, observation, baseTrace);
|
|
171
|
+
const ctx = {
|
|
172
|
+
spec,
|
|
173
|
+
// Inherit the decision-level confidence so the per-action abstention gate
|
|
174
|
+
// doesn't reject a model that reports conviction on the decision (the
|
|
175
|
+
// output contract) rather than on each action.
|
|
176
|
+
decisionConfidence: decision.confidence,
|
|
177
|
+
observation,
|
|
178
|
+
quote,
|
|
179
|
+
writesThisCycle,
|
|
180
|
+
writesToday: state.writesToday,
|
|
181
|
+
openCount,
|
|
182
|
+
cashAvailableMusd,
|
|
183
|
+
openMarginMusd,
|
|
184
|
+
realizedLossTodayMusd,
|
|
185
|
+
targetedPositionIds,
|
|
186
|
+
targetedOrderIds,
|
|
187
|
+
};
|
|
188
|
+
const v = validateAction(action, ctx);
|
|
189
|
+
if (!v.valid) {
|
|
190
|
+
planned.push({
|
|
191
|
+
action,
|
|
192
|
+
accepted: false,
|
|
193
|
+
code: v.code,
|
|
194
|
+
reason: v.reason,
|
|
195
|
+
quote,
|
|
196
|
+
});
|
|
197
|
+
log(`reject ${action.type}: ${v.code} (${v.reason})`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
anyAccepted = true;
|
|
201
|
+
if (action.type === "futures_close" || action.type === "futures_set_sltp") {
|
|
202
|
+
targetedPositionIds.push(action.positionId);
|
|
203
|
+
}
|
|
204
|
+
if (action.type === "spot_cancel") {
|
|
205
|
+
targetedOrderIds.push(action.orderId);
|
|
206
|
+
}
|
|
207
|
+
if (!live) {
|
|
208
|
+
planned.push({ action, accepted: true, quote, executed: false });
|
|
209
|
+
log(`DRY-RUN: would ${action.type}`);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// Deterministic idempotency key: stable per intent, advanced only on
|
|
213
|
+
// confirmed success — a lost response replays, a new intent gets a new key.
|
|
214
|
+
const intentKey = intentKeyOf(action);
|
|
215
|
+
const seq = state.intentSeq[intentKey] ?? 0;
|
|
216
|
+
const idem = `${runId}:${intentKey}:${seq}`;
|
|
217
|
+
const meta = action;
|
|
218
|
+
const trace = makeTrace(runId, decisionId, spec, meta.confidence ?? decision.confidence, meta.rationaleSummary);
|
|
219
|
+
const r = await executeAction(client, action, observation, trace, idem);
|
|
220
|
+
planned.push({
|
|
221
|
+
action,
|
|
222
|
+
accepted: true,
|
|
223
|
+
quote,
|
|
224
|
+
executed: r.ok,
|
|
225
|
+
result: r.data,
|
|
226
|
+
});
|
|
227
|
+
if (r.ok) {
|
|
228
|
+
anyExecuted = true;
|
|
229
|
+
state.intentSeq[intentKey] = seq + 1;
|
|
230
|
+
writesThisCycle += 1;
|
|
231
|
+
state.writesToday += 1;
|
|
232
|
+
if (action.type === "futures_open") {
|
|
233
|
+
openCount += 1;
|
|
234
|
+
openMarginMusd += action.marginMusd;
|
|
235
|
+
}
|
|
236
|
+
// Decrement running cash by what this action consumed (futures margin /
|
|
237
|
+
// spot buy notional / PM stake) so a later action this cycle sees it spent.
|
|
238
|
+
if (cashAvailableMusd != null)
|
|
239
|
+
cashAvailableMusd -= cashConsumed(action, quote);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
anyExecFailed = true;
|
|
243
|
+
}
|
|
244
|
+
log(`${r.ok ? "executed" : "FAILED"} ${action.type} (HTTP ${r.status})`);
|
|
245
|
+
}
|
|
246
|
+
// Reset the reject kill-switch only on real PROGRESS: an accepted-but-FAILED
|
|
247
|
+
// live write is not progress, or a persistently failing live agent would
|
|
248
|
+
// never trip the kill-switch.
|
|
249
|
+
const progressed = live ? anyExecuted : anyAccepted;
|
|
250
|
+
state.consecutiveRejectCycles = progressed
|
|
251
|
+
? 0
|
|
252
|
+
: state.consecutiveRejectCycles + 1;
|
|
253
|
+
state.consecutiveExecFailures =
|
|
254
|
+
anyExecFailed && !anyExecuted ? state.consecutiveExecFailures + 1 : 0;
|
|
255
|
+
state.rateLimitHits = client.rateLimitHits ?? state.rateLimitHits;
|
|
256
|
+
saveState(stateFile, state);
|
|
257
|
+
if (live && anyExecuted)
|
|
258
|
+
await exportRunEvidence(client, runId);
|
|
259
|
+
return { decision: "act", planned, live };
|
|
260
|
+
}
|
|
261
|
+
export async function runLoop(deps, opts = {}) {
|
|
262
|
+
const results = [];
|
|
263
|
+
const cadenceMs = parseCadenceMs(deps.spec.trigger.cadence) ?? 3_600_000;
|
|
264
|
+
const log = deps.log ?? (() => { });
|
|
265
|
+
let cycles = 0;
|
|
266
|
+
for (;;) {
|
|
267
|
+
const r = await runCycle(deps);
|
|
268
|
+
results.push(r);
|
|
269
|
+
cycles += 1;
|
|
270
|
+
if (r.disabled)
|
|
271
|
+
break;
|
|
272
|
+
if (opts.once)
|
|
273
|
+
break;
|
|
274
|
+
if (opts.maxCycles && cycles >= opts.maxCycles)
|
|
275
|
+
break;
|
|
276
|
+
log(`sleeping ${Math.round(cadenceMs / 1000)}s until next cycle`);
|
|
277
|
+
await sleep(cadenceMs);
|
|
278
|
+
}
|
|
279
|
+
return results;
|
|
280
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AgentSpec, ParsedSkill, ResolvedAgent, ResolveIssue } from "./types.js";
|
|
2
|
+
export declare function buildSpec(raw: Record<string, unknown>): AgentSpec;
|
|
3
|
+
export declare function parseSkill(text: string): ParsedSkill;
|
|
4
|
+
export declare function loadSkill(path: string): ParsedSkill;
|
|
5
|
+
export interface LoadedAgent {
|
|
6
|
+
resolved: ResolvedAgent;
|
|
7
|
+
spec: AgentSpec;
|
|
8
|
+
body: string;
|
|
9
|
+
raw: Record<string, unknown>;
|
|
10
|
+
lint: ResolveIssue[];
|
|
11
|
+
}
|
|
12
|
+
export declare function loadAgent(inputPath: string, mode?: "self-host" | "hosted"): LoadedAgent;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
3
|
+
import { VENUES, PROVIDERS, ALLOWED_CAPABILITIES, } from "./types.js";
|
|
4
|
+
import { resolveAgent, ResolveError } from "./resolve.js";
|
|
5
|
+
import { strictLint } from "./strictLint.js";
|
|
6
|
+
import { checkCapabilityDrift } from "./capabilityGuard.js";
|
|
7
|
+
// Safe defaults for the OPTIONAL policy blocks. A minimal self-host skill
|
|
8
|
+
// (name/description/spec/trigger/model/venues/risk) runs under these. Hosted
|
|
9
|
+
// mode requires them to be explicit (see skillValidator).
|
|
10
|
+
const DEFAULT_LIMITS = {
|
|
11
|
+
maxTradesPerDay: 20,
|
|
12
|
+
maxWritesPerCycle: 2,
|
|
13
|
+
maxDailyLossMusd: 5_000,
|
|
14
|
+
maxOpenMarginMusd: 5_000,
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_ABSTENTION = {
|
|
17
|
+
onStaleData: true,
|
|
18
|
+
onWeakSignal: true,
|
|
19
|
+
onMissingQuote: true,
|
|
20
|
+
onInsufficientBalance: true,
|
|
21
|
+
minConfidence: 0,
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_SYNC = { requirePollBeforeWrite: true };
|
|
24
|
+
const DEFAULT_KILLSWITCH = {
|
|
25
|
+
maxDrawdownMusd: 0, // 0 = disabled
|
|
26
|
+
maxConsecutiveRejects: 0, // 0 = disabled
|
|
27
|
+
maxConsecutiveModelFailures: 5,
|
|
28
|
+
onRateLimitPressure: true,
|
|
29
|
+
};
|
|
30
|
+
const num = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
31
|
+
const bool = (v, fallback) => typeof v === "boolean" ? v : fallback;
|
|
32
|
+
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
33
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
34
|
+
? v
|
|
35
|
+
: {};
|
|
36
|
+
function buildObjective(raw) {
|
|
37
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
38
|
+
return undefined;
|
|
39
|
+
const o = raw;
|
|
40
|
+
if (typeof o.primary !== "string")
|
|
41
|
+
return undefined;
|
|
42
|
+
return {
|
|
43
|
+
primary: o.primary,
|
|
44
|
+
secondary: strArr(o.secondary),
|
|
45
|
+
horizon: typeof o.horizon === "string" ? o.horizon : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function buildModel(raw) {
|
|
49
|
+
if (raw === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
const m = obj(raw);
|
|
52
|
+
return {
|
|
53
|
+
provider: (PROVIDERS.includes(m.provider)
|
|
54
|
+
? m.provider
|
|
55
|
+
: "openai-compatible"),
|
|
56
|
+
name: typeof m.name === "string" ? m.name : "",
|
|
57
|
+
baseUrl: typeof m.baseUrl === "string" ? m.baseUrl : undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Coerce raw frontmatter into a best-effort AgentSpec. This NEVER throws on bad
|
|
61
|
+
// values — it fills in what it can and lets validateSkill report problems
|
|
62
|
+
// against the raw frontmatter. The runner only proceeds when validation passes.
|
|
63
|
+
export function buildSpec(raw) {
|
|
64
|
+
const trigger = obj(raw.trigger);
|
|
65
|
+
const risk = obj(raw.risk);
|
|
66
|
+
const limits = obj(raw.limits);
|
|
67
|
+
const abst = obj(raw.abstention);
|
|
68
|
+
const sync = obj(raw.sync);
|
|
69
|
+
const ks = obj(raw.killSwitch);
|
|
70
|
+
const venues = strArr(raw.venues).filter((v) => VENUES.includes(v));
|
|
71
|
+
return {
|
|
72
|
+
name: typeof raw.name === "string" ? raw.name : "",
|
|
73
|
+
description: typeof raw.description === "string" ? raw.description : "",
|
|
74
|
+
spec: typeof raw.spec === "string" ? raw.spec : "",
|
|
75
|
+
trigger: {
|
|
76
|
+
cadence: typeof trigger.cadence === "string" ? trigger.cadence : "",
|
|
77
|
+
timezone: typeof trigger.timezone === "string" ? trigger.timezone : undefined,
|
|
78
|
+
},
|
|
79
|
+
model: buildModel(raw.model),
|
|
80
|
+
venues,
|
|
81
|
+
risk: {
|
|
82
|
+
maxLeverage: num(risk.maxLeverage, 1),
|
|
83
|
+
perTradeMarginMusd: num(risk.perTradeMarginMusd, 0),
|
|
84
|
+
maxConcurrentPositions: num(risk.maxConcurrentPositions, 0),
|
|
85
|
+
requireStopLoss: bool(risk.requireStopLoss, true),
|
|
86
|
+
watchlist: strArr(risk.watchlist),
|
|
87
|
+
blocklist: strArr(risk.blocklist),
|
|
88
|
+
},
|
|
89
|
+
limits: {
|
|
90
|
+
maxTradesPerDay: num(limits.maxTradesPerDay, DEFAULT_LIMITS.maxTradesPerDay),
|
|
91
|
+
maxWritesPerCycle: num(limits.maxWritesPerCycle, DEFAULT_LIMITS.maxWritesPerCycle),
|
|
92
|
+
maxDailyLossMusd: num(limits.maxDailyLossMusd, DEFAULT_LIMITS.maxDailyLossMusd),
|
|
93
|
+
maxOpenMarginMusd: num(limits.maxOpenMarginMusd, DEFAULT_LIMITS.maxOpenMarginMusd),
|
|
94
|
+
},
|
|
95
|
+
abstention: {
|
|
96
|
+
onStaleData: bool(abst.onStaleData, DEFAULT_ABSTENTION.onStaleData),
|
|
97
|
+
onWeakSignal: bool(abst.onWeakSignal, DEFAULT_ABSTENTION.onWeakSignal),
|
|
98
|
+
onMissingQuote: bool(abst.onMissingQuote, DEFAULT_ABSTENTION.onMissingQuote),
|
|
99
|
+
onInsufficientBalance: bool(abst.onInsufficientBalance, DEFAULT_ABSTENTION.onInsufficientBalance),
|
|
100
|
+
minConfidence: num(abst.minConfidence, DEFAULT_ABSTENTION.minConfidence),
|
|
101
|
+
},
|
|
102
|
+
sync: {
|
|
103
|
+
requirePollBeforeWrite: bool(sync.requirePollBeforeWrite, DEFAULT_SYNC.requirePollBeforeWrite),
|
|
104
|
+
},
|
|
105
|
+
killSwitch: {
|
|
106
|
+
maxDrawdownMusd: num(ks.maxDrawdownMusd, DEFAULT_KILLSWITCH.maxDrawdownMusd),
|
|
107
|
+
maxConsecutiveRejects: num(ks.maxConsecutiveRejects, DEFAULT_KILLSWITCH.maxConsecutiveRejects),
|
|
108
|
+
maxConsecutiveModelFailures: num(ks.maxConsecutiveModelFailures, DEFAULT_KILLSWITCH.maxConsecutiveModelFailures),
|
|
109
|
+
onRateLimitPressure: bool(ks.onRateLimitPressure, DEFAULT_KILLSWITCH.onRateLimitPressure),
|
|
110
|
+
},
|
|
111
|
+
objective: buildObjective(raw.objective),
|
|
112
|
+
capabilities: strArr(raw.capabilities).filter((c) => ALLOWED_CAPABILITIES.includes(c)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function parseSkill(text) {
|
|
116
|
+
const { data, body } = parseFrontmatter(text);
|
|
117
|
+
return { spec: buildSpec(data), body, raw: data };
|
|
118
|
+
}
|
|
119
|
+
export function loadSkill(path) {
|
|
120
|
+
return parseSkill(readFileSync(path, "utf8"));
|
|
121
|
+
}
|
|
122
|
+
// Compile an agent (single file OR decomposed folder) into a spec + the prose
|
|
123
|
+
// the LLM reads. Fail-closed: resolveAgent() throws on any structural/secret
|
|
124
|
+
// problem. In hosted mode the strict key/enum lint is ALSO fatal (no silent
|
|
125
|
+
// coercion of a typo'd knob); self-host returns lint as advisory issues.
|
|
126
|
+
export function loadAgent(inputPath, mode = "self-host") {
|
|
127
|
+
const resolved = resolveAgent(inputPath);
|
|
128
|
+
const raw = resolved.rawFrontmatter;
|
|
129
|
+
const spec = buildSpec(raw);
|
|
130
|
+
// strictLint = frontmatter keys/enums; capability drift = prose references to
|
|
131
|
+
// venues/actions/caps the runner (or this agent's venues) does not support.
|
|
132
|
+
const lint = [...strictLint(raw), ...checkCapabilityDrift(resolved, spec)];
|
|
133
|
+
if (mode === "hosted" && lint.length)
|
|
134
|
+
throw new ResolveError(lint);
|
|
135
|
+
return { resolved, spec, body: resolved.mergedProse, raw, lint };
|
|
136
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ParsedSkill, ValidationResult } from "./types.js";
|
|
2
|
+
export type SkillValidationMode = "self-host" | "hosted";
|
|
3
|
+
export interface SkillValidation {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
issues: ValidationResult[];
|
|
6
|
+
}
|
|
7
|
+
export declare function validateSkill(parsed: ParsedSkill, mode?: SkillValidationMode): SkillValidation;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { fail, VENUES, PROVIDERS, SPEC_VERSION, OBJECTIVE_PRIMARIES, ALLOWED_CAPABILITIES, } from "./types.js";
|
|
2
|
+
import { parseCadenceMs, scanForSecrets } from "./util.js";
|
|
3
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
const isPosNum = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
5
|
+
export function validateSkill(parsed, mode = "self-host") {
|
|
6
|
+
const raw = parsed.raw;
|
|
7
|
+
const issues = [];
|
|
8
|
+
const add = (code, reason) => issues.push(fail(code, reason));
|
|
9
|
+
// Secrets must NEVER live in a committable skill file.
|
|
10
|
+
for (const finding of scanForSecrets(raw))
|
|
11
|
+
add("skill_secret", finding);
|
|
12
|
+
// Identity
|
|
13
|
+
if (typeof raw.name !== "string" || !raw.name.trim())
|
|
14
|
+
add("skill_name", "name is required (string)");
|
|
15
|
+
if (typeof raw.description !== "string" || !raw.description.trim())
|
|
16
|
+
add("skill_description", "description is required (string)");
|
|
17
|
+
if (raw.spec !== SPEC_VERSION)
|
|
18
|
+
add("skill_spec_version", `spec must be "${SPEC_VERSION}"`);
|
|
19
|
+
// Trigger
|
|
20
|
+
if (!isObj(raw.trigger)) {
|
|
21
|
+
add("skill_trigger", "trigger block is required");
|
|
22
|
+
}
|
|
23
|
+
else if (parseCadenceMs(raw.trigger.cadence) === null) {
|
|
24
|
+
add("skill_cadence", `trigger.cadence must look like "15m" / "1h" / "4h" (got ${JSON.stringify(raw.trigger.cadence)})`);
|
|
25
|
+
}
|
|
26
|
+
// Venues
|
|
27
|
+
if (!Array.isArray(raw.venues) || raw.venues.length === 0) {
|
|
28
|
+
add("skill_venues", "venues must be a non-empty list");
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
for (const v of raw.venues) {
|
|
32
|
+
if (!VENUES.includes(v))
|
|
33
|
+
add("skill_venue_unknown", `unknown venue "${String(v)}" (allowed: ${VENUES.join(", ")})`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Risk — always required
|
|
37
|
+
if (!isObj(raw.risk)) {
|
|
38
|
+
add("skill_risk", "risk block is required (the caps the agent runs under)");
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const r = raw.risk;
|
|
42
|
+
if (!isPosNum(r.maxLeverage))
|
|
43
|
+
add("skill_risk_leverage", "risk.maxLeverage must be a positive number");
|
|
44
|
+
else if (r.maxLeverage > 20)
|
|
45
|
+
add("skill_risk_leverage_cap", "risk.maxLeverage cannot exceed the server cap of 20");
|
|
46
|
+
if (!isPosNum(r.perTradeMarginMusd))
|
|
47
|
+
add("skill_risk_margin", "risk.perTradeMarginMusd must be a positive number");
|
|
48
|
+
if (!isPosNum(r.maxConcurrentPositions))
|
|
49
|
+
add("skill_risk_positions", "risk.maxConcurrentPositions must be a positive number");
|
|
50
|
+
if (typeof r.requireStopLoss !== "boolean")
|
|
51
|
+
add("skill_risk_sl", "risk.requireStopLoss must be true or false");
|
|
52
|
+
if (!Array.isArray(r.watchlist) || r.watchlist.length === 0)
|
|
53
|
+
add("skill_risk_watchlist", "risk.watchlist must be a non-empty list of symbols");
|
|
54
|
+
}
|
|
55
|
+
// Model
|
|
56
|
+
if (raw.model === undefined) {
|
|
57
|
+
if (mode === "self-host")
|
|
58
|
+
add("skill_model_required", "model is required for self-host (no hosted free-tier locally). Set model.provider + model.name.");
|
|
59
|
+
}
|
|
60
|
+
else if (!isObj(raw.model)) {
|
|
61
|
+
add("skill_model", "model must be an object with provider + name");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
if (!PROVIDERS.includes(raw.model.provider))
|
|
65
|
+
add("skill_model_provider", `model.provider must be one of: ${PROVIDERS.join(", ")}`);
|
|
66
|
+
if (typeof raw.model.name !== "string" || !raw.model.name.trim())
|
|
67
|
+
add("skill_model_name", "model.name is required");
|
|
68
|
+
if (raw.model.provider === "openai-compatible" &&
|
|
69
|
+
(typeof raw.model.baseUrl !== "string" || !raw.model.baseUrl.trim()))
|
|
70
|
+
add("skill_model_baseurl", "model.baseUrl is required for provider 'openai-compatible'");
|
|
71
|
+
}
|
|
72
|
+
// objective (optional)
|
|
73
|
+
if (raw.objective !== undefined) {
|
|
74
|
+
if (!isObj(raw.objective)) {
|
|
75
|
+
add("skill_objective", "objective must be an object");
|
|
76
|
+
}
|
|
77
|
+
else if (typeof raw.objective.primary !== "string" ||
|
|
78
|
+
!OBJECTIVE_PRIMARIES.includes(raw.objective.primary)) {
|
|
79
|
+
add("skill_objective_primary", `objective.primary must be one of: ${OBJECTIVE_PRIMARIES.join(", ")}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// capabilities (optional, opt-in, reserved for a later slice)
|
|
83
|
+
if (raw.capabilities !== undefined) {
|
|
84
|
+
if (!Array.isArray(raw.capabilities)) {
|
|
85
|
+
add("skill_capabilities", "capabilities must be a list");
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
for (const c of raw.capabilities) {
|
|
89
|
+
if (!ALLOWED_CAPABILITIES.includes(c)) {
|
|
90
|
+
add("skill_capability_unknown", `unknown capability "${String(c)}" (allowed: ${ALLOWED_CAPABILITIES.join(", ")})`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Hosted mode: the safety policy blocks must be explicit.
|
|
96
|
+
if (mode === "hosted") {
|
|
97
|
+
if (!isObj(raw.limits)) {
|
|
98
|
+
add("skill_limits_required", "limits block is required in hosted mode");
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
const l = raw.limits;
|
|
102
|
+
if (!isPosNum(l.maxTradesPerDay))
|
|
103
|
+
add("skill_limits_trades", "limits.maxTradesPerDay must be a positive number");
|
|
104
|
+
if (!isPosNum(l.maxWritesPerCycle))
|
|
105
|
+
add("skill_limits_writes", "limits.maxWritesPerCycle must be a positive number");
|
|
106
|
+
if (!isPosNum(l.maxDailyLossMusd))
|
|
107
|
+
add("skill_limits_loss", "limits.maxDailyLossMusd must be a positive number");
|
|
108
|
+
if (!isPosNum(l.maxOpenMarginMusd))
|
|
109
|
+
add("skill_limits_open", "limits.maxOpenMarginMusd must be a positive number");
|
|
110
|
+
}
|
|
111
|
+
if (!isObj(raw.abstention))
|
|
112
|
+
add("skill_abstention_required", "abstention block is required in hosted mode");
|
|
113
|
+
if (!isObj(raw.sync)) {
|
|
114
|
+
add("skill_sync_required", "sync block is required in hosted mode");
|
|
115
|
+
}
|
|
116
|
+
else if (typeof raw.sync.requirePollBeforeWrite !== "boolean") {
|
|
117
|
+
add("skill_sync_poll", "sync.requirePollBeforeWrite must be true or false");
|
|
118
|
+
}
|
|
119
|
+
if (!isObj(raw.killSwitch))
|
|
120
|
+
add("skill_killswitch_required", "killSwitch block is required in hosted mode");
|
|
121
|
+
}
|
|
122
|
+
return { valid: issues.length === 0, issues };
|
|
123
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { RunState, AgentSpec } from "./types.js";
|
|
2
|
+
export declare function newState(runId: string): RunState;
|
|
3
|
+
export declare function loadState(file: string | undefined, runId: string): RunState;
|
|
4
|
+
export declare function saveState(file: string | undefined, state: RunState): void;
|
|
5
|
+
export declare function rollDay(state: RunState): RunState;
|
|
6
|
+
export declare function accrueRealized(state: RunState, closedTrades: Record<string, unknown>[]): void;
|
|
7
|
+
export declare function checkKillSwitch(spec: AgentSpec, state: RunState): string | null;
|