@senpi/runtime 3.0.64 → 3.0.66
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/dist/actions/close-position/close-position.action.d.ts.map +1 -1
- package/dist/actions/close-position/close-position.action.js +8 -3
- package/dist/actions/close-position/close-position.action.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +10 -4
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/cli/deploy-render.d.ts +3 -1
- package/dist/cli/deploy-render.d.ts.map +1 -1
- package/dist/cli/deploy-render.js +233 -11
- package/dist/cli/deploy-render.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +9 -7
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.js +4 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/deploy/deadlines.d.ts +3 -3
- package/dist/deploy/deadlines.js +3 -3
- package/dist/deploy/job.d.ts +16 -0
- package/dist/deploy/job.d.ts.map +1 -1
- package/dist/deploy/job.js +7 -1
- package/dist/deploy/job.js.map +1 -1
- package/dist/deploy/orchestrator.d.ts +2 -1
- package/dist/deploy/orchestrator.d.ts.map +1 -1
- package/dist/deploy/orchestrator.js +402 -84
- package/dist/deploy/orchestrator.js.map +1 -1
- package/dist/deploy/proof-gate.d.ts.map +1 -1
- package/dist/deploy/proof-gate.js +8 -3
- package/dist/deploy/proof-gate.js.map +1 -1
- package/dist/deploy/register.js +1 -1
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +11 -0
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +9 -0
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +32 -3
- package/dist/index.js.map +1 -1
- package/dist/senpi/client.d.ts +60 -1
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +318 -14
- package/dist/senpi/client.js.map +1 -1
- package/dist/senpi/types.d.ts +18 -0
- package/dist/senpi/types.d.ts.map +1 -1
- package/dist/validate/proof.js +4 -1
- package/dist/validate/proof.js.map +1 -1
- package/dist/validate/run.d.ts.map +1 -1
- package/dist/validate/run.js +4 -1
- package/dist/validate/run.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/senpi/client.js
CHANGED
|
@@ -28,12 +28,212 @@ const logger = createLogger("[senpi][client]");
|
|
|
28
28
|
export class SenpiClientError extends Error {
|
|
29
29
|
code;
|
|
30
30
|
details;
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The venue may have acted despite the failure — set when the envelope reports
|
|
33
|
+
* an ambiguous upstream code (the server's own execution-timeout abort) on a
|
|
34
|
+
* mutating call. Callers must reconcile rather than re-send.
|
|
35
|
+
*/
|
|
36
|
+
mayHaveExecuted;
|
|
37
|
+
/** Envelope `error.code`, kept so telemetry and callers can key on it. */
|
|
38
|
+
upstreamCode;
|
|
39
|
+
constructor(code, message, details, opts) {
|
|
40
|
+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
33
41
|
this.name = "SenpiClientError";
|
|
34
42
|
this.code = code;
|
|
35
43
|
this.details = details;
|
|
44
|
+
this.mayHaveExecuted = opts?.mayHaveExecuted ?? false;
|
|
45
|
+
this.upstreamCode = opts?.upstreamCode;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** JSON-RPC code the MCP SDK uses for a client-side request timeout. */
|
|
49
|
+
export const MCP_REQUEST_TIMEOUT_CODE = -32001;
|
|
50
|
+
/**
|
|
51
|
+
* Server-side ALO wait window when the recipe doesn't override it
|
|
52
|
+
* (`FeeOptimizedLimitOptions.executionTimeoutSeconds`, see types.ts).
|
|
53
|
+
*/
|
|
54
|
+
const SERVER_DEFAULT_ALO_SECONDS = 45;
|
|
55
|
+
/**
|
|
56
|
+
* Slack added on top of the ALO budget: the venue round-trip and the taker
|
|
57
|
+
* crossing that `ensure_execution_as_taker` performs *after* the maker window
|
|
58
|
+
* closes both land outside the budget the recipe asked for.
|
|
59
|
+
*/
|
|
60
|
+
const ALO_TIMEOUT_HEADROOM_MS = 30_000;
|
|
61
|
+
/**
|
|
62
|
+
* Floor for a **mutating** MCP request. Sized just above the trading-engine
|
|
63
|
+
* dependency's own ~70s ceiling (453k calls/7d: p50 90ms, p95 19.8s, max 70.0s) —
|
|
64
|
+
* a client that gives up earlier abandons work that then succeeds, and for a
|
|
65
|
+
* mutation that ambiguity is the expensive kind.
|
|
66
|
+
*
|
|
67
|
+
* Deliberately not larger: signal processing is strictly serial (one consumer, see
|
|
68
|
+
* CLAUDE.md), so a hung call blocks every queued signal behind it for the whole
|
|
69
|
+
* timeout. 90s clears the measured ceiling with ~20s of margin while keeping that
|
|
70
|
+
* worst-case stall as short as the evidence allows.
|
|
71
|
+
*/
|
|
72
|
+
const MCP_MUTATION_TIMEOUT_FLOOR_MS = 90_000;
|
|
73
|
+
/**
|
|
74
|
+
* Timeout for a **read**. Much shorter than a mutation's, because the two fail
|
|
75
|
+
* asymmetrically: a timed-out read is idempotent and simply retried, while a
|
|
76
|
+
* timed-out mutation may have executed. Waiting longer on a read buys almost
|
|
77
|
+
* nothing and costs real time in the two places that block on one.
|
|
78
|
+
*
|
|
79
|
+
* The DSL monitor is strictly serial (`dsl/plugin/index.ts` schedules the next
|
|
80
|
+
* tick only after the previous finishes) and wraps its reads in `withRetry(2)`,
|
|
81
|
+
* so the per-tick worst case is 3× this value with no trailing-floor update on
|
|
82
|
+
* live positions. Positions keep their exchange-side stop throughout — what a
|
|
83
|
+
* blind tick loses is the ratchet, not the protection.
|
|
84
|
+
*
|
|
85
|
+
* 45s is >6× the measured p99.9 (`market_get_prices` 7.08s over 4.14M calls,
|
|
86
|
+
* `execution_get_order_status` 2.60s over 2.84M), giving a 135s worst-case blind
|
|
87
|
+
* window against 180s before this change and 270s under a single shared floor.
|
|
88
|
+
*
|
|
89
|
+
* Only reads landing between 45s and the old 60s cutoff fail where they would
|
|
90
|
+
* previously have returned; they are retried, since reads are retriable. The
|
|
91
|
+
* whole 30–60s band is 127 calls/week out of ~7M, so the affected slice is a
|
|
92
|
+
* fraction of 0.002%. Chosen over a tighter 30s (90s worst case) deliberately:
|
|
93
|
+
* the extra 45s of blind window is cheap next to failing reads that were about
|
|
94
|
+
* to succeed, and the telemetry has no 30–45s sub-band to distinguish them.
|
|
95
|
+
*/
|
|
96
|
+
const MCP_READ_TIMEOUT_MS = 45_000;
|
|
97
|
+
/** MCP request timeout for a call carrying an ALO execution budget. */
|
|
98
|
+
export function mcpTimeoutForAlo(opts) {
|
|
99
|
+
const budgetSeconds = opts?.executionTimeoutSeconds ?? SERVER_DEFAULT_ALO_SECONDS;
|
|
100
|
+
return Math.max(budgetSeconds * 1000 + ALO_TIMEOUT_HEADROOM_MS, MCP_MUTATION_TIMEOUT_FLOOR_MS);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A `tools/call` the client stopped waiting for. **Not a failed order.**
|
|
104
|
+
*
|
|
105
|
+
* The server returns 200 on this work — an ALO order is *designed* to occupy the
|
|
106
|
+
* connection for its full `execution_timeout_seconds`, so a timeout means we
|
|
107
|
+
* stopped listening, not that nothing happened. Retrying a timed-out mutation
|
|
108
|
+
* can double it (a second close against a closing position opens the reverse).
|
|
109
|
+
*
|
|
110
|
+
* `mayHaveExecuted` marks that ambiguity so callers reconcile instead of
|
|
111
|
+
* retrying, and so the agent narrates a timeout rather than inventing an auth
|
|
112
|
+
* fault from a bare `exception`.
|
|
113
|
+
*/
|
|
114
|
+
export class SenpiTimeoutError extends Error {
|
|
115
|
+
/**
|
|
116
|
+
* Numeric so `jsonRpcErrorCode` still resolves it: the span attribute
|
|
117
|
+
* `rpc.response.status_code` and the `mcp.call_failed` event stay keyed on
|
|
118
|
+
* `-32001`, which is what existing saved queries and alerts match on — and what
|
|
119
|
+
* this class exists to make visible.
|
|
120
|
+
*/
|
|
121
|
+
code = MCP_REQUEST_TIMEOUT_CODE;
|
|
122
|
+
toolName;
|
|
123
|
+
timeoutMs;
|
|
124
|
+
/** Mutations may have landed server-side; reads cannot have side effects. */
|
|
125
|
+
mayHaveExecuted;
|
|
126
|
+
/** Safe to re-issue verbatim. False for mutations — reconcile instead. */
|
|
127
|
+
retriable;
|
|
128
|
+
constructor(toolName, timeoutMs, mayHaveExecuted, cause) {
|
|
129
|
+
super(mayHaveExecuted
|
|
130
|
+
? `${toolName} timed out after ${Math.round(timeoutMs / 1000)}s waiting for the Senpi MCP server. ` +
|
|
131
|
+
`This is a client-side timeout, NOT a rejected order and NOT an authentication problem — the server ` +
|
|
132
|
+
`may have executed it. Do not retry: read the current position and order state, and act on what is ` +
|
|
133
|
+
`actually there.`
|
|
134
|
+
: `${toolName} timed out after ${Math.round(timeoutMs / 1000)}s waiting for the Senpi MCP server. ` +
|
|
135
|
+
`This is a client-side timeout, NOT an authentication problem. The call has no side effects and is ` +
|
|
136
|
+
`safe to retry.`,
|
|
137
|
+
// Keep the SDK error's stack — the classification replaces the message, not
|
|
138
|
+
// the evidence of where it came from.
|
|
139
|
+
cause !== undefined ? { cause } : undefined);
|
|
140
|
+
this.name = "SenpiTimeoutError";
|
|
141
|
+
this.toolName = toolName;
|
|
142
|
+
this.timeoutMs = timeoutMs;
|
|
143
|
+
this.mayHaveExecuted = mayHaveExecuted;
|
|
144
|
+
this.retriable = !mayHaveExecuted;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Tools with no side effects. Everything else is treated as mutating.
|
|
149
|
+
*
|
|
150
|
+
* Deliberately an allowlist of reads rather than a denylist of writes: an
|
|
151
|
+
* unrecognised tool is then assumed to move money, which costs at most a retry
|
|
152
|
+
* we could have taken. The inverse fails open — a mutation added later would
|
|
153
|
+
* default to retriable, and a re-sent withdraw or close is not recoverable.
|
|
154
|
+
*/
|
|
155
|
+
const READ_ONLY_TOOLS = new Set([
|
|
156
|
+
"audit_query",
|
|
157
|
+
"discovery_get_trader_history",
|
|
158
|
+
"discovery_get_trader_state",
|
|
159
|
+
"execution_get_order_status",
|
|
160
|
+
"market_get_prices",
|
|
161
|
+
"market_list_instruments",
|
|
162
|
+
"ratchet_stop_events",
|
|
163
|
+
"ratchet_stop_list",
|
|
164
|
+
"strategy_get_asset_trading_limits",
|
|
165
|
+
"strategy_get_clearinghouse_state",
|
|
166
|
+
"strategy_get_open_orders",
|
|
167
|
+
"strategy_get_pnl_and_account_value_history",
|
|
168
|
+
"strategy_list",
|
|
169
|
+
// Reached via asToolClient(), which passes arbitrary tool names through — the
|
|
170
|
+
// scanner data providers call these two. Without them a market-data read would
|
|
171
|
+
// be narrated as "the server may have executed it".
|
|
172
|
+
"market_get_asset_data",
|
|
173
|
+
"leaderboard_get_markets",
|
|
174
|
+
]);
|
|
175
|
+
/** True for a `tools/call` that can change venue or account state if it lands. */
|
|
176
|
+
function isMutatingTool(toolName) {
|
|
177
|
+
return !READ_ONLY_TOOLS.has(toolName);
|
|
178
|
+
}
|
|
179
|
+
/** A timeout thrown by the MCP SDK, whatever its surface shape. */
|
|
180
|
+
function isMcpTimeout(err) {
|
|
181
|
+
if (err instanceof SenpiTimeoutError)
|
|
182
|
+
return true;
|
|
183
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
184
|
+
const code = err.code;
|
|
185
|
+
if (code === MCP_REQUEST_TIMEOUT_CODE)
|
|
186
|
+
return true;
|
|
36
187
|
}
|
|
188
|
+
return err instanceof Error && /request timed out/i.test(err.message);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Envelope `error.code` values that leave the outcome unknown.
|
|
192
|
+
*
|
|
193
|
+
* The MCP server aborts its own trading-engine call at
|
|
194
|
+
* `SENPI_TRADING_EXECUTION_TIMEOUT_MS` (70s by default) and reports it as
|
|
195
|
+
* `UNAVAILABLE` via `isNetworkError`. That abort says nothing about whether the
|
|
196
|
+
* venue accepted the order — so for a mutation it carries the same ambiguity a
|
|
197
|
+
* client-side timeout does. `INTERNAL` is the unclassified bucket and is treated
|
|
198
|
+
* the same way.
|
|
199
|
+
*
|
|
200
|
+
* Everything else the server emits — INVALID_ARGUMENT, UNAUTHENTICATED,
|
|
201
|
+
* PERMISSION_DENIED, NOT_FOUND, RESOURCE_EXHAUSTED — is a rejection *before*
|
|
202
|
+
* execution, so those stay retriable.
|
|
203
|
+
*/
|
|
204
|
+
const AMBIGUOUS_UPSTREAM_CODES = new Set(["UNAVAILABLE", "INTERNAL"]);
|
|
205
|
+
/** Envelope `error.code`, wherever the failure surfaced. */
|
|
206
|
+
function envelopeErrorCode(error) {
|
|
207
|
+
if (!isRecord(error))
|
|
208
|
+
return undefined;
|
|
209
|
+
return asString(error.code) ?? undefined;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* True when a `success:false` envelope leaves it unknown whether the venue acted.
|
|
213
|
+
* Only meaningful for a mutating tool: a read that fails changed nothing either way.
|
|
214
|
+
*/
|
|
215
|
+
function envelopeOutcomeUnknown(error) {
|
|
216
|
+
const code = envelopeErrorCode(error);
|
|
217
|
+
return code !== undefined && AMBIGUOUS_UPSTREAM_CODES.has(code);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether a thrown MCP failure is safe to re-issue verbatim.
|
|
221
|
+
*
|
|
222
|
+
* Two distinct paths reach here for the same underlying event — a slow order:
|
|
223
|
+
* the client giving up (`SenpiTimeoutError`) and the *server* giving up at its
|
|
224
|
+
* own 70s cap and returning `UNAVAILABLE`. Raising the client timeout above the
|
|
225
|
+
* server's makes the second the common one, so guarding only the first would
|
|
226
|
+
* move the duplicate-submission hazard rather than remove it.
|
|
227
|
+
*
|
|
228
|
+
* Exported so DSL and action call sites pass it to `withRetry`'s `shouldRetry`
|
|
229
|
+
* rather than each re-deriving the rule.
|
|
230
|
+
*/
|
|
231
|
+
export function isRetriableMcpError(err) {
|
|
232
|
+
if (err instanceof SenpiTimeoutError)
|
|
233
|
+
return err.retriable;
|
|
234
|
+
if (err instanceof SenpiClientError)
|
|
235
|
+
return !err.mayHaveExecuted;
|
|
236
|
+
return true;
|
|
37
237
|
}
|
|
38
238
|
/**
|
|
39
239
|
* Best-effort engine error message extraction. Mirrors the resolver used
|
|
@@ -196,12 +396,21 @@ function assertClosePositionSucceeded(raw, ctx) {
|
|
|
196
396
|
}
|
|
197
397
|
const hasData = "data" in raw;
|
|
198
398
|
if (raw.success === false && (!hasData || raw.data == null)) {
|
|
199
|
-
|
|
399
|
+
// An UNAVAILABLE here is the server's own 70s execution-timeout abort, which
|
|
400
|
+
// says nothing about whether the venue filled. Carry that forward so the DSL
|
|
401
|
+
// reconciles instead of submitting a second close.
|
|
402
|
+
throw new SenpiClientError("UPSTREAM_ERROR", `close_position failed (${ctx.wallet} ${ctx.coin}): ${formatSenpiCloseFailureError(raw.error) || "unknown error"}`, raw.error, {
|
|
403
|
+
mayHaveExecuted: envelopeOutcomeUnknown(raw.error),
|
|
404
|
+
upstreamCode: envelopeErrorCode(raw.error),
|
|
405
|
+
});
|
|
200
406
|
}
|
|
201
407
|
const payload = unwrapEnvelope(raw) ?? raw;
|
|
202
408
|
if (isRecord(payload) && payload.success === false) {
|
|
203
409
|
const detail = formatSenpiCloseFailureError(payload.error);
|
|
204
|
-
throw new
|
|
410
|
+
throw new SenpiClientError("ENGINE_FAILURE", `close_position rejected (${ctx.wallet} ${ctx.coin}): ${detail || "success=false"}`, payload.error, {
|
|
411
|
+
mayHaveExecuted: envelopeOutcomeUnknown(payload.error),
|
|
412
|
+
upstreamCode: envelopeErrorCode(payload.error),
|
|
413
|
+
});
|
|
205
414
|
}
|
|
206
415
|
}
|
|
207
416
|
/**
|
|
@@ -436,9 +645,32 @@ export class SenpiClient {
|
|
|
436
645
|
if (!this.mcpClient) {
|
|
437
646
|
throw new Error('SenpiClient not connected — connect() did not establish a client');
|
|
438
647
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
648
|
+
// A timeout is always passed. Leaving RequestOptions off falls back to the SDK's
|
|
649
|
+
// DEFAULT_REQUEST_TIMEOUT_MSEC (60s), which is below both the ALO budgets recipes
|
|
650
|
+
// request and the trading engine's own ~70s ceiling — so the client abandons work
|
|
651
|
+
// the server then completes.
|
|
652
|
+
//
|
|
653
|
+
// Reads and mutations get different defaults on purpose: they fail asymmetrically,
|
|
654
|
+
// so patience is worth buying for one and not the other. See the two constants.
|
|
655
|
+
const timeoutMs = opts?.timeoutMs ??
|
|
656
|
+
(isMutatingTool(toolName) ? MCP_MUTATION_TIMEOUT_FLOOR_MS : MCP_READ_TIMEOUT_MS);
|
|
657
|
+
const requestOptions = {
|
|
658
|
+
timeout: timeoutMs,
|
|
659
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
660
|
+
};
|
|
661
|
+
let result;
|
|
662
|
+
try {
|
|
663
|
+
result = await this.mcpClient.callTool({ name: toolName, arguments: args }, undefined, requestOptions);
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
// A deliberate cancellation (shutdown drain) stays an AbortError — only a real
|
|
667
|
+
// timeout is reclassified, so callers can tell "we stopped waiting" apart from
|
|
668
|
+
// "it was rejected" and reconcile rather than retry.
|
|
669
|
+
if (opts?.signal?.aborted !== true && isMcpTimeout(err)) {
|
|
670
|
+
throw new SenpiTimeoutError(toolName, timeoutMs, isMutatingTool(toolName), err);
|
|
671
|
+
}
|
|
672
|
+
throw err;
|
|
673
|
+
}
|
|
442
674
|
if (result.content && Array.isArray(result.content) && result.content.length > 0) {
|
|
443
675
|
const part = result.content[0];
|
|
444
676
|
if (part && 'text' in part && typeof part.text === 'string') {
|
|
@@ -561,11 +793,16 @@ export class SenpiClient {
|
|
|
561
793
|
}
|
|
562
794
|
async createPosition(request) {
|
|
563
795
|
logger.debug('createPosition request', { request });
|
|
796
|
+
// Orders are worked one at a time server-side, so the longest single ALO budget
|
|
797
|
+
// bounds the call — not their sum.
|
|
798
|
+
const aloTimeoutMs = Math.max(...request.orders.map((o) => o.orderType === 'FEE_OPTIMIZED_LIMIT'
|
|
799
|
+
? mcpTimeoutForAlo(o.feeOptimizedLimitOptions)
|
|
800
|
+
: MCP_MUTATION_TIMEOUT_FLOOR_MS), MCP_MUTATION_TIMEOUT_FLOOR_MS);
|
|
564
801
|
const raw = await this.callTool('create_position', {
|
|
565
802
|
strategyWalletAddress: request.strategyWalletAddress,
|
|
566
803
|
orders: request.orders,
|
|
567
804
|
...(request.reason != null ? { reason: request.reason } : {}),
|
|
568
|
-
});
|
|
805
|
+
}, { timeoutMs: aloTimeoutMs });
|
|
569
806
|
logger.debug('createPosition raw response', { raw });
|
|
570
807
|
return parseCreatePositionResult(raw);
|
|
571
808
|
}
|
|
@@ -821,7 +1058,7 @@ export class SenpiClient {
|
|
|
821
1058
|
...(params.feeOptimizedLimitOptions ? { feeOptimizedLimitOptions: params.feeOptimizedLimitOptions } : {}),
|
|
822
1059
|
...(params.closeLimitMinutes !== undefined ? { closeLimitMinutes: params.closeLimitMinutes } : {}),
|
|
823
1060
|
...(params.closePeakRoe !== undefined ? { closePeakRoe: params.closePeakRoe } : {}),
|
|
824
|
-
});
|
|
1061
|
+
}, { timeoutMs: mcpTimeoutForAlo(params.feeOptimizedLimitOptions) });
|
|
825
1062
|
assertClosePositionSucceeded(raw, { wallet, coin: effectiveCoin });
|
|
826
1063
|
return unwrapEnvelope(raw) ?? raw;
|
|
827
1064
|
}
|
|
@@ -975,7 +1212,7 @@ export class SenpiClient {
|
|
|
975
1212
|
const data = unwrapEnvelope(raw);
|
|
976
1213
|
if (!isRecord(data))
|
|
977
1214
|
return { success: false };
|
|
978
|
-
return { success: data.success === true,
|
|
1215
|
+
return { success: data.success === true, ...readFailureFields(data) };
|
|
979
1216
|
}
|
|
980
1217
|
/**
|
|
981
1218
|
* Raw portfolio from account_get_portfolio. Interpretation (the funding
|
|
@@ -1002,22 +1239,39 @@ export class SenpiClient {
|
|
|
1002
1239
|
const data = unwrapEnvelope(raw);
|
|
1003
1240
|
if (!isRecord(data))
|
|
1004
1241
|
return { success: false };
|
|
1005
|
-
|
|
1006
|
-
|
|
1242
|
+
// The MCP nests the created record: `data.strategy.{id,strategyWalletAddress,status,…}` beside
|
|
1243
|
+
// an async-start `data.message` — there is no top-level id (senpi-hyperliquid-mcp
|
|
1244
|
+
// `execution.tools.ts`, `createResponse(tool, 'create', { strategy, message })`). Reading only
|
|
1245
|
+
// the flat keys returned `strategyId: undefined` for a create that had already made AND FUNDED
|
|
1246
|
+
// a wallet, which the deploy then reported as a failure.
|
|
1247
|
+
//
|
|
1248
|
+
// The nested record WINS over a flat id when both appear. It is the object the backend
|
|
1249
|
+
// actually created — the same one `strategy_list` returns, carrying the wallet and status the
|
|
1250
|
+
// rest of the deploy binds to — so its id is guaranteed to belong to the wallet that just took
|
|
1251
|
+
// the money. A flat id sitting beside it could only be a second, unattached identifier, and on
|
|
1252
|
+
// a money call the expensive mistake is attaching a funded wallet to the wrong id. Flat shapes
|
|
1253
|
+
// stay accepted for the reason the Python lifecycle reader keeps them (senpi-skills
|
|
1254
|
+
// `_cli.py` `strategy_obj`: list entries "may be flat or wrapped").
|
|
1255
|
+
// Read UNCONDITIONALLY, not only on `success: true`: a create that made and funded a wallet
|
|
1256
|
+
// and then reported it oddly must still hand back the id — losing it is the defect 6f2cd917
|
|
1257
|
+
// fixed, and it costs a funded wallet. A refusal envelope carries no id at any of these keys
|
|
1258
|
+
// (`{ success, error, meta }`), so there is nothing there to mint one out of.
|
|
1259
|
+
const strategyId = readStrategyId(isRecord(data.strategy) ? data.strategy : undefined) ?? readStrategyId(data);
|
|
1260
|
+
return { success: data.success === true, strategyId, ...readFailureFields(data) };
|
|
1007
1261
|
}
|
|
1008
1262
|
async withdrawFunds(wallet, amount) {
|
|
1009
1263
|
const raw = await this.callTool("strategy_withdraw_funds", { strategy_wallet: wallet, amount });
|
|
1010
1264
|
const data = unwrapEnvelope(raw);
|
|
1011
1265
|
if (!isRecord(data))
|
|
1012
1266
|
return { success: false };
|
|
1013
|
-
return { success: data.success === true,
|
|
1267
|
+
return { success: data.success === true, ...readFailureFields(data) };
|
|
1014
1268
|
}
|
|
1015
1269
|
async closeStrategy(wallet, opts) {
|
|
1016
1270
|
const raw = await this.callTool("strategy_close", { strategy_wallet: wallet }, opts);
|
|
1017
1271
|
const data = unwrapEnvelope(raw);
|
|
1018
1272
|
if (!isRecord(data))
|
|
1019
1273
|
return { success: false };
|
|
1020
|
-
return { success: data.success === true,
|
|
1274
|
+
return { success: data.success === true, ...readFailureFields(data) };
|
|
1021
1275
|
}
|
|
1022
1276
|
/**
|
|
1023
1277
|
* Submit a batch close_positions MCP call.
|
|
@@ -1228,6 +1482,56 @@ function extractSlOrderId(unwrapped, raw) {
|
|
|
1228
1482
|
}
|
|
1229
1483
|
return null;
|
|
1230
1484
|
}
|
|
1485
|
+
/**
|
|
1486
|
+
* The `errorCode`/`message` pair for the strategy lifecycle calls that report failure by RETURNING
|
|
1487
|
+
* rather than throwing (`topUp`, `createCustomStrategy`, `withdrawFunds`, `closeStrategy`).
|
|
1488
|
+
*
|
|
1489
|
+
* A backend refusal reaches these methods as `{ success: false, error: { code, message, … }, meta }`
|
|
1490
|
+
* — `createErrorResponse` in senpi-hyperliquid-mcp `src/services/response.service.ts`, returned by
|
|
1491
|
+
* `server.ts` as an ordinary tool result with no `isError`, so nothing throws. That shape has **no
|
|
1492
|
+
* `data` key**, and `unwrapEnvelope` returns any payload without one untouched: `data` therefore IS
|
|
1493
|
+
* the envelope, and the old flat `asString(data.message)` read was looking one level above the
|
|
1494
|
+
* text. Every backend refusal arrived as `message: undefined`, and deploy reported "backend
|
|
1495
|
+
* returned no strategyId" over a refusal that had said exactly why it refused.
|
|
1496
|
+
*
|
|
1497
|
+
* `data.message` stays as the fallback because that is where a SUCCESS envelope puts its text (the
|
|
1498
|
+
* create's async-start message arrives that way), and a string-shaped `error` is read only after it
|
|
1499
|
+
* — `unwrapEnvelope` hoists such an `error` onto the inner record, and other parsers in this client
|
|
1500
|
+
* accept that legacy shape (`parseCreatePositionResult`, `parseClosePositionResult`), though these
|
|
1501
|
+
* four methods never did: at `cfecb432` they read `data.message` and nothing else.
|
|
1502
|
+
*
|
|
1503
|
+
* That last point is why the order is `error.message` → `data.message` → string `error`, and not
|
|
1504
|
+
* the other way round — deliberately UNLIKE `extractEngineMessage` and `parseCreatePositionResult`,
|
|
1505
|
+
* which rank a string `error` above `data.message`. Those read engine mutation results, where
|
|
1506
|
+
* `error` is the failure channel and `message` is often the generic line beside it. These four read
|
|
1507
|
+
* a lifecycle envelope whose `message` is the real text on the success path, and they read nothing
|
|
1508
|
+
* else at `cfecb432`. A payload carrying BOTH a top-level string `error` and a `data.message` is a
|
|
1509
|
+
* shape no MCP response produces (`createResponse` never emits a top-level `error`), so no evidence
|
|
1510
|
+
* decides the tie: what decides it is that ranking the legacy string first would silently change
|
|
1511
|
+
* which text these four answer with — on a SUCCESS envelope — as a side effect of a change about
|
|
1512
|
+
* refusals. The legacy arm keeps its value where it is the ONLY text (`{ success: false,
|
|
1513
|
+
* error: "…" }`, no `data` key and no error object) and takes nothing away where it is not.
|
|
1514
|
+
*
|
|
1515
|
+
* The code is returned as its own field and never concatenated into the message: `deploy`'s
|
|
1516
|
+
* unnamed-create retry branches on it, and a guard that re-derives a code by scanning prose is the
|
|
1517
|
+
* guard this repo already had.
|
|
1518
|
+
*/
|
|
1519
|
+
function readFailureFields(data) {
|
|
1520
|
+
const err = isRecord(data.error) ? data.error : undefined;
|
|
1521
|
+
return {
|
|
1522
|
+
errorCode: (err && asString(err.code)) ?? undefined,
|
|
1523
|
+
message: (err && asString(err.message)) ?? asString(data.message) ?? asString(data.error) ?? undefined,
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The strategy id out of a strategy-shaped record, trying the key spellings the strategy surface
|
|
1528
|
+
* uses in the order the Python lifecycle reader tries them (`_cli.py` `strategy_id_of`).
|
|
1529
|
+
*/
|
|
1530
|
+
function readStrategyId(obj) {
|
|
1531
|
+
if (obj == null)
|
|
1532
|
+
return undefined;
|
|
1533
|
+
return asString(obj.strategyId) ?? asString(obj.id) ?? asString(obj.strategy_id) ?? undefined;
|
|
1534
|
+
}
|
|
1231
1535
|
function parseCreatePositionResult(raw) {
|
|
1232
1536
|
const data = unwrapEnvelope(raw);
|
|
1233
1537
|
if (!isRecord(data)) {
|