@thirdfy/agent-cli 0.2.75 → 0.2.77
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 +39 -0
- package/README.md +6 -3
- package/package.json +4 -4
- package/src/cli/manifest.mjs +3 -3
- package/src/cli/options/bootstrap.mjs +7 -1
- package/src/cli/program.mjs +1 -0
- package/src/cli/register.mjs +3 -0
- package/src/commands/condor.mjs +2 -1
- package/src/commands/telemetry.mjs +1 -1
- package/src/core/actionTimeouts.mjs +56 -10
- package/src/core/context.mjs +5 -5
- package/src/core/hostedRuntimeIdempotency.mjs +28 -0
- package/src/core/http.mjs +92 -0
- package/src/runtime/actions/selection.mjs +11 -2
- package/src/runtime/execution/amounts.mjs +63 -1
- package/src/runtime/execution/runners.mjs +112 -17
- package/src/runtime/main.mjs +3 -1
- package/src/runtime/validation/actionParamsGate.mjs +56 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@thirdfy/agent-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.2.77] - 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Dependencies on current releases:**
|
|
10
|
+
- `commander` 14.0.3: 12 → 14 is two majors.
|
|
11
|
+
- `ethers` 6.17.0 and `@open-wallet-standard/core` 1.4.2.
|
|
12
|
+
- Commander 15 is held back because it needs Node ≥22.12. agent-cli still supports Node ≥20, and Hermes runtimes install `@latest` on the Node their image already has.
|
|
13
|
+
- **Extra positional arguments are still accepted.** Commander 13+ rejects them by default; the CLI opts back in (`allowExcessArguments`), so a command like `intent-status <id>` behaves as in 0.2.76. Pin: `test/commander-routing.test.cjs`.
|
|
14
|
+
- **`-ni` still works.** Commander 13+ rejects two-letter short flags. The option is `--not-interactive`, and a bare `-ni` is rewritten to it before parsing. Pin: `test/commander-routing.test.cjs`.
|
|
15
|
+
- **`venue readiness|setup|fund` declare `<venue>`.** The manifest passed `true` as the argument name, which commander 12 accepted as one required positional. The behavior is unchanged: `thirdfy-agent venue readiness avantis`.
|
|
16
|
+
- **CI and release:**
|
|
17
|
+
- CI tests Node 20, 22 and 24.
|
|
18
|
+
- Release and live e2e run on Node 24.
|
|
19
|
+
- `actions/checkout@v7`, `actions/setup-node@v7`.
|
|
20
|
+
- Dependabot with weekly updates.
|
|
21
|
+
- **agent_wallet reads make one API call.** A read in `agent_wallet` mode skips the `/execution-wallet` preflight, because the API resolves the execution wallet itself. The preflight still runs when the read needs its answer: `--wallet-address` set to a wallet other than the agent key, chain 4663 (Robinhood delegated lane), or a Hyperliquid/Lighter/Avantis address default. With `--wallet-address` equal to the agent key, the wallet the API reports is checked after the read (same `EXECUTION_WALLET_MISMATCH` rule). The result shows `executionWalletPreflight: { skipped: "read_fast_path" }`. Matches `thirdfy-mcp` `walletExecute`.
|
|
22
|
+
- **`WRITE_OUTCOME_UNKNOWN` advice matches `thirdfy-mcp`.** The error names the action and the idempotency key: re-read state first; if the write landed, do not resend; if unsure and retrying, reuse the exact same key; if the re-read shows it did not land, send it with a new key. The API holds an unknown-outcome claim for 24 h.
|
|
23
|
+
- **Unknown actions.** An action that is not in the catalog, or not allowed for the agent key, still fails with `error: PARAMS_SCHEMA_INVALID`, now with `blockedReason: UNKNOWN_ACTION`, `resolvedAction: null`, and `suggestions` (closest catalog actions). Same shape as `thirdfy-mcp` 0.1.6.
|
|
24
|
+
- **Write timeout floor (pin only).** The CLI picks write timeouts from its own read/write classifier, not the catalog `behavior.latencyClass`, so a write never gets the read timeout. `test/mcp-api-parity.test.cjs` pins it.
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- **The agent API key never goes in a URL.** The policy-aware actions listing (`thirdfy-agent actions --agent-api-key …`, and action resolution before `run` / `preflight` / `wallet execute`) called `GET /api/v1/agent/actions?agentApiKey=…`. It now sends the key in the `x-agent-api-key` header, which every agent route reads since Thirdfy API 3.15; a key in the query string gets `Deprecation` headers. Pin: `test/mcp-api-parity.test.cjs`.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Replayed writes are labeled.** When the API answers a value-moving write from an earlier identical one (`Idempotent-Replayed: true` header or `idempotentReplay: true` in the body), the result carries `idempotentReplay: true` and a note: nothing new was executed; send a new `executionIdempotencyKey` (`idempotencyKey` on execute-intent) to place another. Both come from `--idempotency-key`. Matches `thirdfy-mcp` `idempotentReplayFields`.
|
|
33
|
+
- **`deposit_earn_position` amount aliases.** `amountHuman` and `amountInHuman` are accepted in place of `amount` (a decimal token-unit string). Two amounts, or raw base units (`amountInRaw`, `amountIn`), fail with `EARN_AMOUNT_*` before any request. **New refusal:** earn deposits that sent `amountIn` / `amountInRaw` used to reach the API, which reads `amount` as token units; they now fail locally. `execute_optimal_swap` takes the same `amountInRaw` / `amountInHuman` + `tokenInDecimals` contract as `swap`. Matches the `thirdfy-mcp` params gate (`earn_deposit_decimal_amount`, `evm_erc20_amount_in`).
|
|
34
|
+
|
|
35
|
+
## [0.2.76] - 2026-09-23
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Managed writes (perps orders, TP/SL updates, closes) use a 90s HTTP timeout instead of the 30s default. They sign, broadcast, and wait for a receipt in one API call. Reads keep the base timeout; onboarding, earn, and bridge actions keep 180s. Override: `THIRDFY_CLI_WRITE_TIMEOUT_MS` (or `THIRDFY_API_WRITE_TIMEOUT_MS`).
|
|
40
|
+
- `requestJson` resends a timed-out request only when it is a GET, a validation-only preview (`validationOnly` or `executionLane: validation_only`), or marked `writeAction: false` (read rail, build-tx, telemetry, governance preflight, execute-intent preflight). Every other POST (venue fund, delegation, onboarding) returns `WRITE_OUTCOME_UNKNOWN` instead of a second send. Read/write classification uses catalog metadata when the resolved action is available.
|
|
41
|
+
- Avantis market open/close (`place_avantis_perps_order`, `close_avantis_position`) use the 180s long timeout; the API streams, then polls, for up to about 180s.
|
|
42
|
+
- A write whose request times out is not resent. The retry could double a trade that already executed. The CLI throws `WRITE_OUTCOME_UNKNOWN` (`nextAction: reread_state_before_retry`). Preflight (`validationOnly`) and reads still retry once. Pin: `test/actionTimeouts.test.cjs`. Pairs with `thirdfy-mcp` **0.0.125**.
|
|
43
|
+
|
|
5
44
|
## [0.2.75] - 2026-09-21
|
|
6
45
|
|
|
7
46
|
### Fixed
|
package/README.md
CHANGED
|
@@ -40,10 +40,13 @@ Run without global install:
|
|
|
40
40
|
npx @thirdfy/agent-cli --help
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
## What's new in v0.2.
|
|
43
|
+
## What's new in v0.2.77
|
|
44
44
|
|
|
45
|
-
-
|
|
46
|
-
-
|
|
45
|
+
- The agent API key is sent in the `x-agent-api-key` header, never in a URL.
|
|
46
|
+
- A write the API answers from an earlier identical one is marked `idempotentReplay: true`. Send a new `--idempotency-key` to place another.
|
|
47
|
+
- `agent_wallet` reads make one API call (no separate wallet preflight when the read does not need it).
|
|
48
|
+
- `deposit_earn_position` accepts `amountHuman` / `amountInHuman` as `amount`. Unknown actions report `blockedReason: UNKNOWN_ACTION` with `suggestions`.
|
|
49
|
+
- Built on commander 14 and ethers 6.17; Node 20+ is still supported.
|
|
47
50
|
- See [CHANGELOG.md](./CHANGELOG.md) and GitHub Releases for older versions.
|
|
48
51
|
|
|
49
52
|
## Quick start
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thirdfy/agent-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.77",
|
|
4
4
|
"description": "Thirdfy Agent CLI for onboarding, governance preflight, execute-intent, and status polling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
},
|
|
72
72
|
"homepage": "https://github.com/thirdfy/agent-cli#readme",
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@open-wallet-standard/core": "^1.
|
|
75
|
-
"commander": "^
|
|
76
|
-
"ethers": "^6.
|
|
74
|
+
"@open-wallet-standard/core": "^1.4.2",
|
|
75
|
+
"commander": "^14.0.3",
|
|
76
|
+
"ethers": "^6.17.0"
|
|
77
77
|
}
|
|
78
78
|
}
|
package/src/cli/manifest.mjs
CHANGED
|
@@ -104,7 +104,7 @@ export const CLI_MANIFEST = [
|
|
|
104
104
|
handler: 'commandVenueReadiness',
|
|
105
105
|
options: ['auth', 'venue'],
|
|
106
106
|
description: 'Normalized venue readiness (polymarket, hyperliquid, lighter, avantis)',
|
|
107
|
-
args: { query:
|
|
107
|
+
args: { query: '<venue>' },
|
|
108
108
|
tier: 'online',
|
|
109
109
|
},
|
|
110
110
|
{
|
|
@@ -112,7 +112,7 @@ export const CLI_MANIFEST = [
|
|
|
112
112
|
handler: 'commandVenueSetup',
|
|
113
113
|
options: ['auth', 'venue'],
|
|
114
114
|
description: 'Prepare or complete venue onboarding via shared API contract',
|
|
115
|
-
args: { query:
|
|
115
|
+
args: { query: '<venue>' },
|
|
116
116
|
tier: 'online',
|
|
117
117
|
},
|
|
118
118
|
{
|
|
@@ -120,7 +120,7 @@ export const CLI_MANIFEST = [
|
|
|
120
120
|
handler: 'commandVenueFund',
|
|
121
121
|
options: ['auth', 'venue'],
|
|
122
122
|
description: 'Return venue funding plan (does not move funds implicitly)',
|
|
123
|
-
args: { query:
|
|
123
|
+
args: { query: '<venue>' },
|
|
124
124
|
tier: 'online',
|
|
125
125
|
},
|
|
126
126
|
{
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** Commander 13+ rejects multi-character short flags; keep the historical `-ni` spelling working. */
|
|
2
|
+
export function normalizeLegacyShortFlags(argv) {
|
|
3
|
+
const end = argv.indexOf('--');
|
|
4
|
+
return argv.map((arg, index) => (arg === '-ni' && (end === -1 || index < end) ? '--not-interactive' : arg));
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export function attachBootstrapOptions(cmd) {
|
|
2
8
|
return cmd
|
|
3
9
|
.option('--proof-type <type>', 'session_token|wallet_signature')
|
|
@@ -10,7 +16,7 @@ export function attachBootstrapOptions(cmd) {
|
|
|
10
16
|
.option('--otp <otp>', 'email OTP alias')
|
|
11
17
|
.option('--accept-terms', 'accept terms for email login')
|
|
12
18
|
.option('--key-name <name>', 'Privy key name')
|
|
13
|
-
.option('
|
|
19
|
+
.option('--not-interactive', 'fail fast when required login input is missing (alias: -ni)')
|
|
14
20
|
.option('--no-bootstrap-agent', 'skip agent bootstrap on email login')
|
|
15
21
|
.option('--rotate-agent-key', 'rotate agent key')
|
|
16
22
|
.option('--login-attempt-id <id>', 'email login attempt id')
|
package/src/cli/program.mjs
CHANGED
|
@@ -3,3 +3,4 @@ import { buildCliProgram } from './register.mjs';
|
|
|
3
3
|
export { buildCliProgram };
|
|
4
4
|
export { collectRootFlags, collectLegacyFlags } from './flags.mjs';
|
|
5
5
|
export { configureCommanderErrors, handleRootOnlyFlows, collectVersionFlagsFromArgv } from './errors.mjs';
|
|
6
|
+
export { normalizeLegacyShortFlags } from './options/bootstrap.mjs';
|
package/src/cli/register.mjs
CHANGED
|
@@ -107,6 +107,9 @@ function registerManifestEntry(program, entry, nodeByPath, deps) {
|
|
|
107
107
|
export function buildCliProgram(deps) {
|
|
108
108
|
const program = new Command('thirdfy-agent');
|
|
109
109
|
program.name('thirdfy-agent');
|
|
110
|
+
// Commander 13+ rejects extra positional arguments by default. 0.2.76 (commander 12) ignored them,
|
|
111
|
+
// and agents that pass an id positionally (`intent-status <id>`) must keep working.
|
|
112
|
+
program.allowExcessArguments(true);
|
|
110
113
|
attachGlobalOptions(program);
|
|
111
114
|
program.addHelpCommand(false);
|
|
112
115
|
program.showHelpAfterError(false);
|
package/src/commands/condor.mjs
CHANGED
|
@@ -108,7 +108,8 @@ export function createCondorCommands({ commandRun, printEnvelope }) {
|
|
|
108
108
|
...params,
|
|
109
109
|
},
|
|
110
110
|
};
|
|
111
|
-
|
|
111
|
+
// Preflight only evaluates policy, so a timed-out request is safe to resend.
|
|
112
|
+
const data = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/governance/preflight', body, {
|
|
112
113
|
'x-agent-api-key': agentApiKey,
|
|
113
114
|
});
|
|
114
115
|
printEnvelope({
|
|
@@ -122,7 +122,7 @@ async function commandTrackAction(ctx, flags, capabilities) {
|
|
|
122
122
|
if (flags.decision) payload.decision = parseJsonFlag(flags, 'decision');
|
|
123
123
|
if (flags.proof) payload.proof = parseJsonFlag(flags, 'proof');
|
|
124
124
|
|
|
125
|
-
const response = await apiPost(ctx, '/api/v1/agent/telemetry/actions', payload);
|
|
125
|
+
const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/telemetry/actions', payload);
|
|
126
126
|
printEnvelope({
|
|
127
127
|
success: Boolean(response?.success !== false),
|
|
128
128
|
code: response?.success === false ? 'TRACK_ACTION_FAILED' : 'TRACK_ACTION_OK',
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { DEFAULT_TIMEOUT_MS } from './constants.mjs';
|
|
2
|
+
import { isReadOnlyActionName, isReadOnlyResolvedAction } from './readOnlyActions.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default HTTP timeout for managed writes. A perps order or TP/SL update signs, broadcasts, and waits
|
|
6
|
+
* for a receipt inside one API call; that routinely takes longer than a read. Mirrors thirdfy-mcp
|
|
7
|
+
* `THIRDFY_API_WRITE_TIMEOUT_MS`.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_WRITE_TIMEOUT_MS = 90_000;
|
|
2
10
|
|
|
3
11
|
const LONG_RUNNING_ACTIONS = new Set([
|
|
4
12
|
'complete_avantis_onboarding',
|
|
@@ -21,6 +29,10 @@ const LONG_RUNNING_ACTIONS = new Set([
|
|
|
21
29
|
'swap',
|
|
22
30
|
'execute_optimal_swap',
|
|
23
31
|
'deposit_hyperliquid_cctp',
|
|
32
|
+
// Avantis market open/close: the API streams the batched order for up to 90s, then polls its
|
|
33
|
+
// tracking status for up to 90s more before it answers.
|
|
34
|
+
'place_avantis_perps_order',
|
|
35
|
+
'close_avantis_position',
|
|
24
36
|
]);
|
|
25
37
|
|
|
26
38
|
function normalizeActionId(action) {
|
|
@@ -30,17 +42,51 @@ function normalizeActionId(action) {
|
|
|
30
42
|
.toLowerCase();
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
function positiveEnvMs(...names) {
|
|
46
|
+
for (const name of names) {
|
|
47
|
+
const value = Number(process.env[name]);
|
|
48
|
+
if (Number.isFinite(value) && value > 0) return value;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when the action changes state (anything the read-only classifier does not claim). */
|
|
54
|
+
export function isWriteAction(action) {
|
|
55
|
+
const key = normalizeActionId(action);
|
|
56
|
+
return Boolean(key) && !isReadOnlyActionName(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* HTTP timeout for one action. Long-running onboarding, earn, bridge, and compose actions get the
|
|
61
|
+
* long timeout. Other writes get the write timeout (90s). Reads keep the base timeout. Pass
|
|
62
|
+
* `options.write` when the caller already classified the action (catalog metadata).
|
|
63
|
+
*/
|
|
64
|
+
export function resolveActionTimeoutMs(action, baseTimeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
|
|
35
65
|
const base = Number.isFinite(baseTimeoutMs) && baseTimeoutMs > 0 ? baseTimeoutMs : DEFAULT_TIMEOUT_MS;
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
66
|
+
if (LONG_RUNNING_ACTIONS.has(normalizeActionId(action))) {
|
|
67
|
+
const longMs = positiveEnvMs('THIRDFY_CLI_LONG_TIMEOUT_MS', 'THIRDFY_API_LONG_TIMEOUT_MS') ?? 180_000;
|
|
68
|
+
return Math.max(base, longMs);
|
|
69
|
+
}
|
|
70
|
+
const write = typeof options.write === 'boolean' ? options.write : isWriteAction(action);
|
|
71
|
+
if (write) {
|
|
72
|
+
const writeMs =
|
|
73
|
+
positiveEnvMs('THIRDFY_CLI_WRITE_TIMEOUT_MS', 'THIRDFY_API_WRITE_TIMEOUT_MS') ?? DEFAULT_WRITE_TIMEOUT_MS;
|
|
74
|
+
return Math.max(base, writeMs);
|
|
75
|
+
}
|
|
76
|
+
return base;
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Context for one action's HTTP calls. Accepts the action id or the resolved catalog action, whose
|
|
81
|
+
* metadata can mark a read the name does not. `writeAction` tells `requestJson` not to resend a
|
|
82
|
+
* write whose request timed out: the API may have broadcast it, so the outcome is unknown until
|
|
83
|
+
* state is re-read.
|
|
84
|
+
*/
|
|
85
|
+
export function withActionTimeout(ctx, actionOrResolved) {
|
|
86
|
+
const resolved = actionOrResolved && typeof actionOrResolved === 'object' ? actionOrResolved : null;
|
|
87
|
+
const action = resolved ? resolved.resolvedAction : actionOrResolved;
|
|
88
|
+
const write = resolved
|
|
89
|
+
? Boolean(normalizeActionId(action)) && !isReadOnlyResolvedAction(resolved)
|
|
90
|
+
: isWriteAction(action);
|
|
91
|
+
return { ...ctx, timeoutMs: resolveActionTimeoutMs(action, ctx.timeoutMs, { write }), writeAction: write };
|
|
46
92
|
}
|
package/src/core/context.mjs
CHANGED
|
@@ -131,11 +131,11 @@ export async function getCachedActionsCatalog(ctx, flags) {
|
|
|
131
131
|
if (hit && hit.expiresAt > now) {
|
|
132
132
|
return hit.actions;
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
// The agent key goes in the `x-agent-api-key` header, never the URL: URLs land in access logs and
|
|
135
|
+
// proxies, and API 3.15 answers a URL key with Deprecation headers.
|
|
136
|
+
const response = policyAware
|
|
137
|
+
? await apiGet(ctx, '/api/v1/agent/actions', { 'x-agent-api-key': String(flags.agentApiKey) })
|
|
138
|
+
: await apiGet(ctx, '/api/v1/agent/actions/catalog');
|
|
139
139
|
const actions = extractActions(response);
|
|
140
140
|
catalogCache.set(cacheKey, {
|
|
141
141
|
actions,
|
|
@@ -42,3 +42,31 @@ export function wrapWriteIdempotencyKeyForHostedRuntime(existingKey, runtimeId,
|
|
|
42
42
|
}
|
|
43
43
|
return existing;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Told to the caller when the API answered a write from an earlier identical one. */
|
|
47
|
+
export const idempotentReplayNote = (keyArg = 'executionIdempotencyKey') =>
|
|
48
|
+
`This result is from an earlier identical write; nothing new was executed. Send a new ${keyArg} to place another.`;
|
|
49
|
+
|
|
50
|
+
export const IDEMPOTENT_REPLAY_NOTE = idempotentReplayNote();
|
|
51
|
+
|
|
52
|
+
function hasReplayFlag(value, depth) {
|
|
53
|
+
if (!value || typeof value !== 'object' || depth > 4) return false;
|
|
54
|
+
if (Array.isArray(value)) return value.some((item) => hasReplayFlag(item, depth + 1));
|
|
55
|
+
if (value.idempotentReplay === true) return true;
|
|
56
|
+
return Object.values(value).some((item) => hasReplayFlag(item, depth + 1));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `{ idempotentReplay: true, note }` when the API replayed an earlier write with the same key:
|
|
61
|
+
* `idempotentReplay: true` anywhere in the body, or an `Idempotent-Replayed: true` header (API
|
|
62
|
+
* 3.15). The note names the key field to change (`executionIdempotencyKey` on /execute,
|
|
63
|
+
* `idempotencyKey` on /execute-intent; both come from `--idempotency-key`). Mirrors thirdfy-mcp
|
|
64
|
+
* `idempotentReplayFields`.
|
|
65
|
+
*/
|
|
66
|
+
export function idempotentReplayFields(data, headers, keyArg) {
|
|
67
|
+
const header = headers
|
|
68
|
+
? Object.entries(headers).find(([name]) => name.toLowerCase() === 'idempotent-replayed')?.[1]
|
|
69
|
+
: undefined;
|
|
70
|
+
const replayed = String(header ?? '').trim().toLowerCase() === 'true' || hasReplayFlag(data, 0);
|
|
71
|
+
return replayed ? { idempotentReplay: true, note: idempotentReplayNote(keyArg) } : {};
|
|
72
|
+
}
|
package/src/core/http.mjs
CHANGED
|
@@ -12,6 +12,94 @@ function isRetryableApiStatus(statusCode) {
|
|
|
12
12
|
return statusCode === 502 || statusCode === 503 || statusCode === 504;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function isTimeoutError(error) {
|
|
16
|
+
const message = String(error?.message || '').toLowerCase();
|
|
17
|
+
return error?.name === 'AbortError' || message.includes('abort') || message.includes('timeout');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The idempotency key a write body carries, and the field that carries it. */
|
|
21
|
+
function writeIdempotencyKey(init) {
|
|
22
|
+
try {
|
|
23
|
+
const body = JSON.parse(String(init?.body || '{}'));
|
|
24
|
+
const executionKey = String(body?.executionIdempotencyKey || '').trim();
|
|
25
|
+
if (executionKey) return { key: executionKey, keyArg: 'executionIdempotencyKey', action: body?.action };
|
|
26
|
+
const intentKey = String(body?.idempotencyKey || '').trim();
|
|
27
|
+
if (intentKey) return { key: intentKey, keyArg: 'idempotencyKey', action: body?.action };
|
|
28
|
+
return { key: '', keyArg: 'executionIdempotencyKey', action: body?.action };
|
|
29
|
+
} catch {
|
|
30
|
+
return { key: '', keyArg: 'executionIdempotencyKey', action: undefined };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A write whose request timed out may already be on chain. Resending it can double a trade, so the
|
|
36
|
+
* caller gets `WRITE_OUTCOME_UNKNOWN` and re-reads state before any retry. The advice matches
|
|
37
|
+
* thirdfy-mcp `thirdfyWriteOutcomeUnknownPayload`: reusing the same key lets the API answer with
|
|
38
|
+
* the first write's result if it landed (the API holds an unknown-outcome claim for 24 h).
|
|
39
|
+
*/
|
|
40
|
+
function writeOutcomeUnknownError(route, ctx, cause, init) {
|
|
41
|
+
const { key, keyArg, action } = writeIdempotencyKey(init);
|
|
42
|
+
const retryAdvice = key
|
|
43
|
+
? ` If it landed, do not send it again. If you cannot tell and retry, reuse this exact ${keyArg} ("${key}") so the API cannot run it twice. If the re-read shows it did not land, send it again with a new ${keyArg}.`
|
|
44
|
+
: '';
|
|
45
|
+
const details = `Thirdfy did not answer within ${Math.round(Number(ctx.timeoutMs || 0) / 1000)}s. The write may have executed. Re-read positions, orders, or balances first, and do not resend the same write blindly.${retryAdvice}`;
|
|
46
|
+
const error = new Error(`WRITE_OUTCOME_UNKNOWN: ${route} timed out after ${ctx.timeoutMs}ms. ${details}`);
|
|
47
|
+
error.code = 'WRITE_OUTCOME_UNKNOWN';
|
|
48
|
+
error.statusCode = 504;
|
|
49
|
+
error.payload = {
|
|
50
|
+
error: 'WRITE_OUTCOME_UNKNOWN',
|
|
51
|
+
code: 504,
|
|
52
|
+
...(action ? { action: String(action) } : {}),
|
|
53
|
+
...(key ? { [keyArg]: key } : {}),
|
|
54
|
+
details,
|
|
55
|
+
nextAction: 'reread_state_before_retry',
|
|
56
|
+
timeoutMs: ctx.timeoutMs,
|
|
57
|
+
};
|
|
58
|
+
error.cause = cause;
|
|
59
|
+
return error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Response headers a caller may need after `requestJson` returns only the parsed body. Kept off the
|
|
64
|
+
* body itself so nothing extra is printed. Today: `Idempotent-Replayed` (API 3.15).
|
|
65
|
+
*/
|
|
66
|
+
const responseHeadersByBody = new WeakMap();
|
|
67
|
+
|
|
68
|
+
const TRACKED_RESPONSE_HEADERS = ['idempotent-replayed'];
|
|
69
|
+
|
|
70
|
+
function rememberResponseHeaders(data, response) {
|
|
71
|
+
if (!data || typeof data !== 'object' || typeof response?.headers?.get !== 'function') return;
|
|
72
|
+
const tracked = {};
|
|
73
|
+
for (const name of TRACKED_RESPONSE_HEADERS) {
|
|
74
|
+
const value = response.headers.get(name);
|
|
75
|
+
if (value !== null && value !== undefined) tracked[name] = String(value);
|
|
76
|
+
}
|
|
77
|
+
if (Object.keys(tracked).length) responseHeadersByBody.set(data, tracked);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tracked response headers (lowercase names) for a body returned by `apiGet` / `apiPost`. */
|
|
81
|
+
export function responseHeadersFor(data) {
|
|
82
|
+
return (data && typeof data === 'object' && responseHeadersByBody.get(data)) || {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a request that timed out may be sent again. GETs, validation-only previews, and calls the
|
|
87
|
+
* caller marks `writeAction: false` are safe. Preflight sends `executionLane: validation_only`
|
|
88
|
+
* (not a `validationOnly` field). Any other POST changes state (an order, a venue funding, a
|
|
89
|
+
* delegation grant), so the default is not to resend it.
|
|
90
|
+
*/
|
|
91
|
+
function resendAfterTimeoutIsSafe(ctx, init) {
|
|
92
|
+
if (ctx?.writeAction === true) return false;
|
|
93
|
+
if (ctx?.writeAction === false) return true;
|
|
94
|
+
if (String(init?.method || 'GET').toUpperCase() !== 'POST') return true;
|
|
95
|
+
try {
|
|
96
|
+
const body = JSON.parse(String(init?.body || '{}'));
|
|
97
|
+
return body?.validationOnly === true || body?.executionLane === 'validation_only';
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
15
103
|
function isRetryableApiError(error) {
|
|
16
104
|
const statusCode = Number(error?.statusCode || 0);
|
|
17
105
|
const message = String(error?.message || '').toLowerCase();
|
|
@@ -58,6 +146,7 @@ async function requestJsonOnce(ctx, route, init) {
|
|
|
58
146
|
});
|
|
59
147
|
const text = await response.text();
|
|
60
148
|
const data = text ? safeJsonParse(text) : {};
|
|
149
|
+
rememberResponseHeaders(data, response);
|
|
61
150
|
if (!response.ok) {
|
|
62
151
|
const error = new Error(data?.error || data?.message || `HTTP ${response.status}`);
|
|
63
152
|
error.statusCode = response.status;
|
|
@@ -77,6 +166,9 @@ export async function requestJson(ctx, route, init) {
|
|
|
77
166
|
return await requestJsonOnce(ctx, route, init);
|
|
78
167
|
} catch (error) {
|
|
79
168
|
lastError = error;
|
|
169
|
+
if (isTimeoutError(error) && !resendAfterTimeoutIsSafe(ctx, init)) {
|
|
170
|
+
throw writeOutcomeUnknownError(route, ctx, error, init);
|
|
171
|
+
}
|
|
80
172
|
if (attempt === 0 && isRetryableApiError(error)) {
|
|
81
173
|
await sleep(250 + Math.floor(Math.random() * 250));
|
|
82
174
|
continue;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { requireFlag } from '../../core/args.mjs';
|
|
2
2
|
import { getCachedActionsCatalog } from '../../core/context.mjs';
|
|
3
|
+
import { suggestCatalogActions } from '../validation/actionParamsGate.mjs';
|
|
3
4
|
export function createActionSelection({ getTradingProviderHint, formatProviderAmbiguousActionHint, formatProviderUnknownActionHint }) {
|
|
4
5
|
function getActionKey(action) {
|
|
5
6
|
return String(action?.action || action?.id || action?.key || action?.name || '')
|
|
@@ -361,7 +362,7 @@ function resolveActionFromRows(requestedAction, actions, flags = {}) {
|
|
|
361
362
|
.filter((entry) => entry.key);
|
|
362
363
|
|
|
363
364
|
if (!keyed.length) {
|
|
364
|
-
return { requestedAction, resolvedAction: requestedAction, resolvedActionMeta: null };
|
|
365
|
+
return { requestedAction, resolvedAction: requestedAction, resolvedActionMeta: null, suggestions: [] };
|
|
365
366
|
}
|
|
366
367
|
|
|
367
368
|
const canonicalAliasOverrides = {
|
|
@@ -462,7 +463,15 @@ function resolveActionFromRows(requestedAction, actions, flags = {}) {
|
|
|
462
463
|
)}?${formatProviderAmbiguousActionHint(providerHint)}`
|
|
463
464
|
);
|
|
464
465
|
}
|
|
465
|
-
return {
|
|
466
|
+
return {
|
|
467
|
+
requestedAction,
|
|
468
|
+
resolvedAction: requestedAction,
|
|
469
|
+
resolvedActionMeta: null,
|
|
470
|
+
suggestions: suggestCatalogActions(
|
|
471
|
+
requestedAction,
|
|
472
|
+
keyed.map((entry) => ({ ...entry.action, action: entry.key })),
|
|
473
|
+
),
|
|
474
|
+
};
|
|
466
475
|
}
|
|
467
476
|
|
|
468
477
|
return {
|
|
@@ -3,7 +3,62 @@ import { createCliError } from '../../core/envelope.mjs';
|
|
|
3
3
|
export function createExecutionAmounts() {
|
|
4
4
|
function shouldApplySwapAmountContract(actionName) {
|
|
5
5
|
const action = String(actionName || '').trim().toLowerCase();
|
|
6
|
-
return action === 'swap' || /(^|:|-)swap$/.test(action);
|
|
6
|
+
return action === 'swap' || action === 'execute_optimal_swap' || /(^|:|-)swap$/.test(action);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeContractActionKey(actionName) {
|
|
10
|
+
return String(actionName || '').trim().toLowerCase().replace(/_/g, '-');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `deposit_earn_position` takes `amount` as a decimal token-unit string (contract `earn_deposit_decimal_amount`). */
|
|
14
|
+
function shouldApplyEarnDepositAmountContract(actionName) {
|
|
15
|
+
return /(^|:)deposit-earn-position$/.test(normalizeContractActionKey(actionName));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasParamValue(value) {
|
|
19
|
+
return value !== undefined && value !== null && String(value).trim() !== '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Accepts `amountHuman` or `amountInHuman` in place of `amount`, as thirdfy-mcp's params gate does
|
|
24
|
+
* (`earn_deposit_decimal_amount`). Exactly one decimal amount; raw base units are refused, because
|
|
25
|
+
* the API reads `amount` as token units.
|
|
26
|
+
*/
|
|
27
|
+
function normalizeEarnDepositAmountContract(params) {
|
|
28
|
+
const hasAmount = hasParamValue(params.amount);
|
|
29
|
+
const hasAmountHuman = hasParamValue(params.amountHuman);
|
|
30
|
+
const hasAmountInHuman = hasParamValue(params.amountInHuman);
|
|
31
|
+
const humanInputs = [hasAmount, hasAmountHuman, hasAmountInHuman].filter(Boolean).length;
|
|
32
|
+
if (humanInputs > 1) {
|
|
33
|
+
throw createCliError(
|
|
34
|
+
'EARN_AMOUNT_AMBIGUOUS',
|
|
35
|
+
'Provide exactly one earn deposit amount: amount, amountHuman, or amountInHuman.'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (humanInputs === 1) {
|
|
39
|
+
if (hasParamValue(params.amountInRaw) || hasParamValue(params.amountIn)) {
|
|
40
|
+
throw createCliError(
|
|
41
|
+
'EARN_AMOUNT_UNIT_AMBIGUOUS',
|
|
42
|
+
'Provide exactly one earn deposit amount; do not combine amount/amountHuman/amountInHuman with amountInRaw or amountIn.'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const amount = String(hasAmount ? params.amount : hasAmountHuman ? params.amountHuman : params.amountInHuman).trim();
|
|
46
|
+
if (!/^\d+(\.\d+)?$/.test(amount)) {
|
|
47
|
+
throw createCliError(
|
|
48
|
+
'EARN_AMOUNT_INVALID',
|
|
49
|
+
'Earn deposits require amount as a decimal token-unit string (example: "100.5").'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const { amountHuman: _amountHuman, amountInHuman: _amountInHuman, ...rest } = params;
|
|
53
|
+
return { ...rest, amount };
|
|
54
|
+
}
|
|
55
|
+
if (hasParamValue(params.amountInRaw) || hasParamValue(params.amountIn)) {
|
|
56
|
+
throw createCliError(
|
|
57
|
+
'EARN_AMOUNT_UNIT_INVALID',
|
|
58
|
+
'deposit_earn_position expects amount as a decimal token-unit string. Do not pass raw base-unit amountInRaw/amountIn for earn deposits.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return params;
|
|
7
62
|
}
|
|
8
63
|
|
|
9
64
|
function normalizeIntegerRawAmount(input, code = 'AMOUNT_UNIT_INVALID') {
|
|
@@ -164,6 +219,11 @@ function normalizeSwapAmountContract(params) {
|
|
|
164
219
|
|
|
165
220
|
function prepareActionParamsForFlags(flags, resolvedAction) {
|
|
166
221
|
const parsedParams = parseJsonFlag(flags, 'params');
|
|
222
|
+
if (shouldApplyEarnDepositAmountContract(resolvedAction)) {
|
|
223
|
+
flags.__preparedParams = normalizeEarnDepositAmountContract(parsedParams || {});
|
|
224
|
+
flags.__swapInput = null;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
167
227
|
if (!shouldApplySwapAmountContract(resolvedAction)) {
|
|
168
228
|
flags.__preparedParams = parsedParams;
|
|
169
229
|
flags.__swapInput = null;
|
|
@@ -187,6 +247,8 @@ function getPreparedParams(flags) {
|
|
|
187
247
|
decimalToRawUnits,
|
|
188
248
|
rawUnitsToDecimalString,
|
|
189
249
|
normalizeSwapAmountContract,
|
|
250
|
+
shouldApplyEarnDepositAmountContract,
|
|
251
|
+
normalizeEarnDepositAmountContract,
|
|
190
252
|
prepareActionParamsForFlags,
|
|
191
253
|
getPreparedParams,
|
|
192
254
|
};
|
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
getEffectiveRunMode,
|
|
10
10
|
parseBooleanFlag,
|
|
11
11
|
} from '../../core/runMode.mjs';
|
|
12
|
-
import { apiGet, apiPost } from '../../core/http.mjs';
|
|
12
|
+
import { apiGet, apiPost, responseHeadersFor } from '../../core/http.mjs';
|
|
13
|
+
import { idempotentReplayFields } from '../../core/hostedRuntimeIdempotency.mjs';
|
|
13
14
|
import { withActionTimeout } from '../../core/actionTimeouts.mjs';
|
|
14
15
|
import { isReadOnlyResolvedAction } from '../../core/readOnlyActions.mjs';
|
|
15
16
|
import { resolveWalletExecuteChainId } from '../../core/walletExecuteChainId.mjs';
|
|
@@ -32,6 +33,7 @@ export function createExecutionRunners(deps) {
|
|
|
32
33
|
buildBitfinexOnboardingCommand,
|
|
33
34
|
inferBitfinexPermissionProfileFromAction,
|
|
34
35
|
resolveAgentApiKey,
|
|
36
|
+
resolveAgentKey,
|
|
35
37
|
loadEthers,
|
|
36
38
|
signAndBroadcastWithOws,
|
|
37
39
|
readEffectBalances,
|
|
@@ -146,9 +148,52 @@ function normalizeManagedExecuteResponse(response, { preflight, payload, isRead
|
|
|
146
148
|
shaped?.amountNormalization || shaped?.data?.amountNormalization || null;
|
|
147
149
|
normalized.raw = shaped;
|
|
148
150
|
normalized.idempotencyKey = payload.executionIdempotencyKey || null;
|
|
151
|
+
// A replay means nothing new ran; say so, or the caller reads it as a fresh fill.
|
|
152
|
+
if (!isRead) {
|
|
153
|
+
Object.assign(
|
|
154
|
+
normalized,
|
|
155
|
+
idempotentReplayFields(response, responseHeadersFor(response), 'executionIdempotencyKey'),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
149
158
|
return normalized;
|
|
150
159
|
}
|
|
151
160
|
|
|
161
|
+
const ROBINHOOD_MAINNET_CHAIN_ID = 4663;
|
|
162
|
+
|
|
163
|
+
/** Marker a read on the fast path carries instead of an execution-wallet preflight. */
|
|
164
|
+
const READ_FAST_PATH_PREFLIGHT = Object.freeze({ success: true, skipped: 'read_fast_path' });
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* agent_wallet reads skip the /execution-wallet preflight: the API resolves the agent's execution
|
|
168
|
+
* wallet itself, so the extra round trip only matters when a read uses its answer: a strict wallet
|
|
169
|
+
* match, a funding check, or the Robinhood delegated lane. An expected wallet equal to the agent key
|
|
170
|
+
* is not a separate wallet to match first; the wallet the API reports is checked after the read.
|
|
171
|
+
* Mirrors thirdfy-mcp `walletExecute` readFastPath.
|
|
172
|
+
*/
|
|
173
|
+
function shouldUseManagedReadFastPath(flags, resolved, options = {}) {
|
|
174
|
+
if (!isReadOnlyAction(resolved)) return false;
|
|
175
|
+
if (options.requireFundingCheck === true) return false;
|
|
176
|
+
const runMode = String(flags.runMode || 'agent_wallet').trim().toLowerCase();
|
|
177
|
+
if (runMode !== 'agent_wallet') return false;
|
|
178
|
+
const expectedWallet = String(flags.walletAddress || flags.executionWalletAddress || '').trim().toLowerCase();
|
|
179
|
+
const agentKey = String(typeof resolveAgentKey === 'function' ? resolveAgentKey(flags) : flags.agentKey || '')
|
|
180
|
+
.trim()
|
|
181
|
+
.toLowerCase();
|
|
182
|
+
if (expectedWallet && expectedWallet !== agentKey) return false;
|
|
183
|
+
let chainId;
|
|
184
|
+
try {
|
|
185
|
+
chainId = resolveWalletExecuteChainId({
|
|
186
|
+
action: String(resolved?.resolvedAction || flags.action || '').trim(),
|
|
187
|
+
chainId: flags.chainId,
|
|
188
|
+
params: getPreparedParams(flags),
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
// Unresolvable chain (e.g. a CCTP quote without sourceChainId): the preflight path reports it.
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return Number(chainId) !== ROBINHOOD_MAINNET_CHAIN_ID;
|
|
195
|
+
}
|
|
196
|
+
|
|
152
197
|
async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
|
|
153
198
|
const resolvedAction = resolved.resolvedAction;
|
|
154
199
|
if (runMode === 'agent_wallet') {
|
|
@@ -192,7 +237,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
|
|
|
192
237
|
runMode: 'self',
|
|
193
238
|
mirrorOnly: false,
|
|
194
239
|
});
|
|
195
|
-
const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
|
|
240
|
+
const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', payload);
|
|
196
241
|
const normalized = normalizeIntentResponse(response);
|
|
197
242
|
const blockedCount = normalized.blocked || 0;
|
|
198
243
|
if (!normalized.success || blockedCount > 0) {
|
|
@@ -240,7 +285,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
|
|
|
240
285
|
mirrorOnly: false,
|
|
241
286
|
hybridWalletMode: options.hybridWalletMode,
|
|
242
287
|
});
|
|
243
|
-
const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
|
|
288
|
+
const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', payload);
|
|
244
289
|
const normalized = normalizeIntentResponse(response);
|
|
245
290
|
const blockedCount = normalized.blocked || 0;
|
|
246
291
|
if (!normalized.success || blockedCount > 0) {
|
|
@@ -267,7 +312,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
|
|
|
267
312
|
mirrorOnly: false,
|
|
268
313
|
hybridWalletMode: 'agent_wallet',
|
|
269
314
|
});
|
|
270
|
-
const managedResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', managedPayload);
|
|
315
|
+
const managedResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', managedPayload);
|
|
271
316
|
const managedNormalized = normalizeIntentResponse(managedResponse);
|
|
272
317
|
const mirrorPayload = buildIntentPayload(flags, {
|
|
273
318
|
validationOnly: true,
|
|
@@ -277,7 +322,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
|
|
|
277
322
|
mirrorOnly: true,
|
|
278
323
|
hybridWalletMode: 'agent_wallet',
|
|
279
324
|
});
|
|
280
|
-
const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
|
|
325
|
+
const mirrorResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', mirrorPayload);
|
|
281
326
|
const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
|
|
282
327
|
return {
|
|
283
328
|
message:
|
|
@@ -304,7 +349,7 @@ async function runPreflightByMode(runMode, ctx, flags, resolved, options = {}) {
|
|
|
304
349
|
runMode,
|
|
305
350
|
mirrorOnly: runMode === 'hybrid',
|
|
306
351
|
});
|
|
307
|
-
const response = await apiPost(ctx, '/api/v1/agent/execute-intent', payload);
|
|
352
|
+
const response = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', payload);
|
|
308
353
|
const normalized = normalizeIntentResponse(response);
|
|
309
354
|
return {
|
|
310
355
|
message: normalized.success
|
|
@@ -403,7 +448,7 @@ async function runExecutionByMode(runMode, ctx, flags, resolved, options) {
|
|
|
403
448
|
|
|
404
449
|
async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'thirdfy') {
|
|
405
450
|
const skipPreflight = Boolean(options?.skipPreflight);
|
|
406
|
-
const actionCtx = withActionTimeout(ctx, resolved
|
|
451
|
+
const actionCtx = withActionTimeout(ctx, resolved);
|
|
407
452
|
if (!skipPreflight) {
|
|
408
453
|
const preflightPayload = buildIntentPayload(flags, {
|
|
409
454
|
validationOnly: true,
|
|
@@ -413,7 +458,12 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
|
|
|
413
458
|
mirrorOnly: false,
|
|
414
459
|
hybridWalletMode: options?.hybridWalletMode,
|
|
415
460
|
});
|
|
416
|
-
|
|
461
|
+
// validationOnly never writes, so a timed-out preflight is safe to resend.
|
|
462
|
+
const preflight = await apiPost(
|
|
463
|
+
{ ...actionCtx, writeAction: false },
|
|
464
|
+
'/api/v1/agent/execute-intent',
|
|
465
|
+
preflightPayload,
|
|
466
|
+
);
|
|
417
467
|
const preflightNormalized = normalizeIntentResponse(preflight);
|
|
418
468
|
const blockedCount = preflightNormalized.blocked || 0;
|
|
419
469
|
if (!preflightNormalized.success || blockedCount > 0) {
|
|
@@ -434,6 +484,9 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
|
|
|
434
484
|
const response = await apiPost(actionCtx, '/api/v1/agent/execute-intent', payload);
|
|
435
485
|
const normalized = normalizeIntentResponse(response);
|
|
436
486
|
normalized.idempotencyKey = payload.idempotencyKey;
|
|
487
|
+
if (!isReadOnlyAction(resolved)) {
|
|
488
|
+
Object.assign(normalized, idempotentReplayFields(response, responseHeadersFor(response), 'idempotencyKey'));
|
|
489
|
+
}
|
|
437
490
|
return normalized;
|
|
438
491
|
}
|
|
439
492
|
|
|
@@ -460,7 +513,7 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
|
|
|
460
513
|
intentResult.routeFallback = 'execute_intent_read_only';
|
|
461
514
|
if (preflight) {
|
|
462
515
|
intentResult.executionWalletPreflight = preflight;
|
|
463
|
-
intentResult.executionWalletAddress = preflight.executionWalletAddress || null;
|
|
516
|
+
intentResult.executionWalletAddress = preflight.executionWalletAddress || intentResult.executionWalletAddress || null;
|
|
464
517
|
}
|
|
465
518
|
intentResult.signerMethod = 'managed_wallet_server';
|
|
466
519
|
return intentResult;
|
|
@@ -474,8 +527,11 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
|
|
|
474
527
|
// (often as a JSON string under data.result). Reads skip funding gates; routing still fails closed.
|
|
475
528
|
const isRead = isReadOnlyAction(resolved);
|
|
476
529
|
const skipPreflight = Boolean(options?.skipPreflight);
|
|
477
|
-
|
|
478
|
-
|
|
530
|
+
// A seeded preflight (Hyperliquid/Lighter/Avantis address defaults) already ran; reuse it.
|
|
531
|
+
const readFastPath =
|
|
532
|
+
!skipPreflight && !options?.managedPreflight && shouldUseManagedReadFastPath(flags, resolved, options);
|
|
533
|
+
let preflight = readFastPath ? READ_FAST_PATH_PREFLIGHT : null;
|
|
534
|
+
if (!skipPreflight && !readFastPath) {
|
|
479
535
|
preflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
|
|
480
536
|
runMode: 'agent_wallet',
|
|
481
537
|
requireFundingCheck: !isRead,
|
|
@@ -503,10 +559,21 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
|
|
|
503
559
|
runMode: 'agent_wallet',
|
|
504
560
|
forceIdempotency: true,
|
|
505
561
|
});
|
|
506
|
-
const actionCtx = withActionTimeout(ctx, resolved
|
|
562
|
+
const actionCtx = withActionTimeout(ctx, resolved);
|
|
507
563
|
const response = await apiPost(actionCtx, '/api/v1/agent/execute', payload);
|
|
564
|
+
if (readFastPath) {
|
|
565
|
+
const mismatch = fastPathWalletMismatch(flags, response);
|
|
566
|
+
if (mismatch) return mismatch;
|
|
567
|
+
}
|
|
508
568
|
if (shouldFallbackAgentWalletToExecuteIntent(resolved, response)) {
|
|
509
|
-
|
|
569
|
+
// On the fast path the wallet comes from the /execute answer, not a preflight.
|
|
570
|
+
const fallbackPreflight = readFastPath
|
|
571
|
+
? {
|
|
572
|
+
...READ_FAST_PATH_PREFLIGHT,
|
|
573
|
+
executionWalletAddress: response?.executionWalletAddress || response?.data?.executionWalletAddress || null,
|
|
574
|
+
}
|
|
575
|
+
: preflight;
|
|
576
|
+
const intentResult = await runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, fallbackPreflight);
|
|
510
577
|
if (isEmptyQueuedExecuteIntent(intentResult.raw || intentResult)) {
|
|
511
578
|
intentResult.success = false;
|
|
512
579
|
intentResult.status = 'failed';
|
|
@@ -522,6 +589,33 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
|
|
|
522
589
|
return normalizeManagedExecuteResponse(response, { preflight, payload, isRead });
|
|
523
590
|
}
|
|
524
591
|
|
|
592
|
+
/**
|
|
593
|
+
* The fast path only runs when the expected wallet is empty or equals the agent key. The preflight
|
|
594
|
+
* path refuses a wallet that differs from the execution wallet, so apply the same rule to the wallet
|
|
595
|
+
* the API reports for the read.
|
|
596
|
+
*/
|
|
597
|
+
function fastPathWalletMismatch(flags, response) {
|
|
598
|
+
const expectedWallet = String(flags.walletAddress || flags.executionWalletAddress || '').trim().toLowerCase();
|
|
599
|
+
if (!expectedWallet || parseBooleanFlag(flags.allowWalletMismatch, false)) return null;
|
|
600
|
+
const apiWallet = String(response?.executionWalletAddress || response?.data?.executionWalletAddress || '')
|
|
601
|
+
.trim()
|
|
602
|
+
.toLowerCase();
|
|
603
|
+
if (!apiWallet || apiWallet === expectedWallet) return null;
|
|
604
|
+
const normalized = normalizeIntentResponse({
|
|
605
|
+
success: false,
|
|
606
|
+
status: 'failed',
|
|
607
|
+
mode: 'agent_wallet',
|
|
608
|
+
blockedReason: 'EXECUTION_WALLET_MISMATCH',
|
|
609
|
+
blockedStage: 'routing',
|
|
610
|
+
error: `Funded wallet (${expectedWallet}) does not match execution wallet (${apiWallet}). Use --allow-wallet-mismatch to override.`,
|
|
611
|
+
preflightBlocked: true,
|
|
612
|
+
executionWalletAddress: apiWallet,
|
|
613
|
+
});
|
|
614
|
+
normalized.executionWalletAddress = apiWallet;
|
|
615
|
+
normalized.executionWalletPreflight = READ_FAST_PATH_PREFLIGHT;
|
|
616
|
+
return normalized;
|
|
617
|
+
}
|
|
618
|
+
|
|
525
619
|
async function executeSelfRun(ctx, flags, resolved, options) {
|
|
526
620
|
const skipPreflight = Boolean(options?.skipPreflight);
|
|
527
621
|
const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
|
|
@@ -539,7 +633,7 @@ async function executeSelfRun(ctx, flags, resolved, options) {
|
|
|
539
633
|
runMode: 'self',
|
|
540
634
|
mirrorOnly: false,
|
|
541
635
|
});
|
|
542
|
-
const preflight = await apiPost(ctx, '/api/v1/agent/execute-intent', preflightPayload);
|
|
636
|
+
const preflight = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', preflightPayload);
|
|
543
637
|
const preflightNormalized = normalizeIntentResponse(preflight);
|
|
544
638
|
const blockedCount = preflightNormalized.blocked || 0;
|
|
545
639
|
if (!preflightNormalized.success || blockedCount > 0) {
|
|
@@ -561,7 +655,8 @@ async function executeSelfRun(ctx, flags, resolved, options) {
|
|
|
561
655
|
return normalized;
|
|
562
656
|
}
|
|
563
657
|
const buildTxPayload = buildBuildTxPayload(flags, resolved.resolvedAction);
|
|
564
|
-
|
|
658
|
+
// build-tx returns an unsigned transaction; nothing is broadcast, so a timeout may be resent.
|
|
659
|
+
const buildTx = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/build-tx', buildTxPayload);
|
|
565
660
|
return normalizeIntentResponse({
|
|
566
661
|
...buildTx,
|
|
567
662
|
status: buildTx?.status || 'ready',
|
|
@@ -598,7 +693,7 @@ async function executeHybridRun(ctx, flags, resolved, options) {
|
|
|
598
693
|
mirrorOnly: true,
|
|
599
694
|
hybridWalletMode,
|
|
600
695
|
});
|
|
601
|
-
const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
|
|
696
|
+
const mirrorResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', mirrorPayload);
|
|
602
697
|
const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
|
|
603
698
|
return normalizeIntentResponse({
|
|
604
699
|
success: Boolean(managedResult.success && mirrorNormalized.success),
|
|
@@ -624,7 +719,7 @@ async function executeHybridRun(ctx, flags, resolved, options) {
|
|
|
624
719
|
runMode: 'hybrid',
|
|
625
720
|
mirrorOnly: true,
|
|
626
721
|
});
|
|
627
|
-
const mirrorResponse = await apiPost(ctx, '/api/v1/agent/execute-intent', mirrorPayload);
|
|
722
|
+
const mirrorResponse = await apiPost({ ...ctx, writeAction: false }, '/api/v1/agent/execute-intent', mirrorPayload);
|
|
628
723
|
const mirrorNormalized = normalizeIntentResponse(mirrorResponse);
|
|
629
724
|
return normalizeIntentResponse({
|
|
630
725
|
success: Boolean(selfResult.success && mirrorNormalized.success),
|
package/src/runtime/main.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
collectRootFlags,
|
|
7
7
|
handleRootOnlyFlows,
|
|
8
8
|
collectVersionFlagsFromArgv,
|
|
9
|
+
normalizeLegacyShortFlags,
|
|
9
10
|
} from '../cli/program.mjs';
|
|
10
11
|
import { createInitialRuntimeContext, applyFlagsToRuntimeContext } from './context.mjs';
|
|
11
12
|
import { createProviderHints } from './providerHints.mjs';
|
|
@@ -87,6 +88,7 @@ export function createThirdfyAgentRuntime() {
|
|
|
87
88
|
buildBitfinexOnboardingCommand: onboarding.buildBitfinexOnboardingCommand,
|
|
88
89
|
inferBitfinexPermissionProfileFromAction: onboarding.inferBitfinexPermissionProfileFromAction,
|
|
89
90
|
resolveAgentApiKey: payloads.resolveAgentApiKey,
|
|
91
|
+
resolveAgentKey: payloads.resolveAgentKey,
|
|
90
92
|
loadEthers: ows.loadEthers,
|
|
91
93
|
signAndBroadcastWithOws: ows.signAndBroadcastWithOws,
|
|
92
94
|
readEffectBalances: ows.readEffectBalances,
|
|
@@ -154,7 +156,7 @@ export function createThirdfyAgentRuntime() {
|
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
try {
|
|
157
|
-
await cliProgram.parseAsync(process.argv, { from: 'node' });
|
|
159
|
+
await cliProgram.parseAsync(normalizeLegacyShortFlags(process.argv), { from: 'node' });
|
|
158
160
|
const versionHandled = await handleRootOnlyFlows(cliProgram, rootFlowDeps);
|
|
159
161
|
if (versionHandled) return;
|
|
160
162
|
} catch (error) {
|
|
@@ -6,6 +6,47 @@ import {
|
|
|
6
6
|
formatParamsSchemaValidationResult,
|
|
7
7
|
} from './paramsSchema.mjs';
|
|
8
8
|
|
|
9
|
+
const tokensOf = (action) =>
|
|
10
|
+
String(action || '')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.split(/[-_\s]+/)
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
|
|
15
|
+
function editDistance(a, b) {
|
|
16
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
17
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
18
|
+
let prev = row[0];
|
|
19
|
+
row[0] = i;
|
|
20
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
21
|
+
const tmp = row[j];
|
|
22
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
23
|
+
prev = tmp;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return row[b.length];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Catalog actions closest to an unknown name: shared words first, then edit distance, so
|
|
31
|
+
* `open_avantis_position` suggests `place_avantis_perps_order` and typos find their action.
|
|
32
|
+
* Mirrors thirdfy-mcp `suggestCatalogActions`.
|
|
33
|
+
*/
|
|
34
|
+
export function suggestCatalogActions(requested, actions, limit = 5) {
|
|
35
|
+
const wanted = tokensOf(requested);
|
|
36
|
+
const target = wanted.join('_');
|
|
37
|
+
return (Array.isArray(actions) ? actions : [])
|
|
38
|
+
.filter((action) => action?.action && action.enabled !== false)
|
|
39
|
+
.map((action) => {
|
|
40
|
+
const name = tokensOf(action.action).join('_');
|
|
41
|
+
const shared = tokensOf(action.action).filter((token) => wanted.includes(token)).length;
|
|
42
|
+
return { name: action.action, shared, distance: editDistance(target, name) };
|
|
43
|
+
})
|
|
44
|
+
.filter((candidate) => candidate.shared > 0 || candidate.distance <= 3)
|
|
45
|
+
.sort((a, b) => b.shared - a.shared || a.distance - b.distance)
|
|
46
|
+
.slice(0, limit)
|
|
47
|
+
.map((candidate) => candidate.name);
|
|
48
|
+
}
|
|
49
|
+
|
|
9
50
|
export function resolveActionParamsSchema(resolved) {
|
|
10
51
|
const meta = resolved?.resolvedActionMeta;
|
|
11
52
|
if (!meta || typeof meta !== 'object') return null;
|
|
@@ -19,12 +60,22 @@ export function isReadOnlyCatalogAction(resolved) {
|
|
|
19
60
|
export function validateResolvedActionParams(resolved, params) {
|
|
20
61
|
const meta = resolved?.resolvedActionMeta;
|
|
21
62
|
if (!meta || typeof meta !== 'object') {
|
|
63
|
+
// Same shape as thirdfy-mcp 0.1.6+: `error` stays PARAMS_SCHEMA_INVALID (callers match on it);
|
|
64
|
+
// `blockedReason: UNKNOWN_ACTION` plus `suggestions` point at real actions. With an agent key
|
|
65
|
+
// the list is the agent's own policy list, so the action may exist but not be allowed.
|
|
66
|
+
const suggestions = Array.isArray(resolved?.suggestions) ? resolved.suggestions : [];
|
|
22
67
|
return {
|
|
23
68
|
valid: false,
|
|
24
69
|
skipped: false,
|
|
25
|
-
error: `Action "${resolved?.requestedAction || ''}"
|
|
70
|
+
error: `Action "${resolved?.requestedAction || ''}" is not in the Thirdfy catalog or is not allowed for this agent${
|
|
71
|
+
suggestions.length
|
|
72
|
+
? `. Did you mean: ${suggestions.join(', ')}?`
|
|
73
|
+
: '. Use `thirdfy-agent actions` to list actions.'
|
|
74
|
+
}`,
|
|
26
75
|
field: null,
|
|
27
76
|
code: 'PARAMS_SCHEMA_INVALID',
|
|
77
|
+
blockedReason: 'UNKNOWN_ACTION',
|
|
78
|
+
suggestions,
|
|
28
79
|
};
|
|
29
80
|
}
|
|
30
81
|
|
|
@@ -57,11 +108,13 @@ export function enforceCatalogActionParams(resolved, flags, getPreparedParams) {
|
|
|
57
108
|
return;
|
|
58
109
|
}
|
|
59
110
|
throw createCliError('PARAMS_SCHEMA_INVALID', result.error || 'Action params failed catalog schema validation', {
|
|
60
|
-
blockedReason: 'PARAMS_SCHEMA_INVALID',
|
|
111
|
+
blockedReason: result.blockedReason || 'PARAMS_SCHEMA_INVALID',
|
|
112
|
+
...(Array.isArray(result.suggestions) ? { suggestions: result.suggestions } : {}),
|
|
61
113
|
blockedStage: 'client_validation',
|
|
62
114
|
validationError: result.error || null,
|
|
63
115
|
field: result.field || null,
|
|
64
|
-
|
|
116
|
+
// An unknown action resolved to nothing; MCP reports `resolvedAction: null` for it too.
|
|
117
|
+
resolvedAction: result.blockedReason === 'UNKNOWN_ACTION' ? null : resolved?.resolvedAction || null,
|
|
65
118
|
requestedAction: resolved?.requestedAction || null,
|
|
66
119
|
});
|
|
67
120
|
}
|