@thirdfy/agent-cli 0.2.76 → 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 +30 -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/core/context.mjs +5 -5
- package/src/core/hostedRuntimeIdempotency.mjs +28 -0
- package/src/core/http.mjs +58 -7
- package/src/runtime/actions/selection.mjs +11 -2
- package/src/runtime/execution/amounts.mjs +63 -1
- package/src/runtime/execution/runners.mjs +94 -5
- package/src/runtime/main.mjs +3 -1
- package/src/runtime/validation/actionParamsGate.mjs +56 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
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
|
+
|
|
5
35
|
## [0.2.76] - 2026-09-23
|
|
6
36
|
|
|
7
37
|
### 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
|
-
- A write
|
|
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/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
|
@@ -17,21 +17,71 @@ function isTimeoutError(error) {
|
|
|
17
17
|
return error?.name === 'AbortError' || message.includes('abort') || message.includes('timeout');
|
|
18
18
|
}
|
|
19
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
|
+
|
|
20
34
|
/**
|
|
21
35
|
* A write whose request timed out may already be on chain. Resending it can double a trade, so the
|
|
22
|
-
* caller gets `WRITE_OUTCOME_UNKNOWN` and re-reads state before any retry.
|
|
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).
|
|
23
39
|
*/
|
|
24
|
-
function writeOutcomeUnknownError(route, ctx, cause) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
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}`);
|
|
28
47
|
error.code = 'WRITE_OUTCOME_UNKNOWN';
|
|
29
48
|
error.statusCode = 504;
|
|
30
|
-
error.payload = {
|
|
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
|
+
};
|
|
31
58
|
error.cause = cause;
|
|
32
59
|
return error;
|
|
33
60
|
}
|
|
34
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
|
+
|
|
35
85
|
/**
|
|
36
86
|
* Whether a request that timed out may be sent again. GETs, validation-only previews, and calls the
|
|
37
87
|
* caller marks `writeAction: false` are safe. Preflight sends `executionLane: validation_only`
|
|
@@ -96,6 +146,7 @@ async function requestJsonOnce(ctx, route, init) {
|
|
|
96
146
|
});
|
|
97
147
|
const text = await response.text();
|
|
98
148
|
const data = text ? safeJsonParse(text) : {};
|
|
149
|
+
rememberResponseHeaders(data, response);
|
|
99
150
|
if (!response.ok) {
|
|
100
151
|
const error = new Error(data?.error || data?.message || `HTTP ${response.status}`);
|
|
101
152
|
error.statusCode = response.status;
|
|
@@ -116,7 +167,7 @@ export async function requestJson(ctx, route, init) {
|
|
|
116
167
|
} catch (error) {
|
|
117
168
|
lastError = error;
|
|
118
169
|
if (isTimeoutError(error) && !resendAfterTimeoutIsSafe(ctx, init)) {
|
|
119
|
-
throw writeOutcomeUnknownError(route, ctx, error);
|
|
170
|
+
throw writeOutcomeUnknownError(route, ctx, error, init);
|
|
120
171
|
}
|
|
121
172
|
if (attempt === 0 && isRetryableApiError(error)) {
|
|
122
173
|
await sleep(250 + Math.floor(Math.random() * 250));
|
|
@@ -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') {
|
|
@@ -439,6 +484,9 @@ async function executeThirdfyRun(ctx, flags, resolved, options, runMode = 'third
|
|
|
439
484
|
const response = await apiPost(actionCtx, '/api/v1/agent/execute-intent', payload);
|
|
440
485
|
const normalized = normalizeIntentResponse(response);
|
|
441
486
|
normalized.idempotencyKey = payload.idempotencyKey;
|
|
487
|
+
if (!isReadOnlyAction(resolved)) {
|
|
488
|
+
Object.assign(normalized, idempotentReplayFields(response, responseHeadersFor(response), 'idempotencyKey'));
|
|
489
|
+
}
|
|
442
490
|
return normalized;
|
|
443
491
|
}
|
|
444
492
|
|
|
@@ -465,7 +513,7 @@ async function runReadOnlyActionViaIntentRail(ctx, flags, resolved, options, pre
|
|
|
465
513
|
intentResult.routeFallback = 'execute_intent_read_only';
|
|
466
514
|
if (preflight) {
|
|
467
515
|
intentResult.executionWalletPreflight = preflight;
|
|
468
|
-
intentResult.executionWalletAddress = preflight.executionWalletAddress || null;
|
|
516
|
+
intentResult.executionWalletAddress = preflight.executionWalletAddress || intentResult.executionWalletAddress || null;
|
|
469
517
|
}
|
|
470
518
|
intentResult.signerMethod = 'managed_wallet_server';
|
|
471
519
|
return intentResult;
|
|
@@ -479,8 +527,11 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
|
|
|
479
527
|
// (often as a JSON string under data.result). Reads skip funding gates; routing still fails closed.
|
|
480
528
|
const isRead = isReadOnlyAction(resolved);
|
|
481
529
|
const skipPreflight = Boolean(options?.skipPreflight);
|
|
482
|
-
|
|
483
|
-
|
|
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) {
|
|
484
535
|
preflight = await resolveManagedExecutionPreflight(ctx, flags, resolved, {
|
|
485
536
|
runMode: 'agent_wallet',
|
|
486
537
|
requireFundingCheck: !isRead,
|
|
@@ -510,8 +561,19 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
|
|
|
510
561
|
});
|
|
511
562
|
const actionCtx = withActionTimeout(ctx, resolved);
|
|
512
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
|
+
}
|
|
513
568
|
if (shouldFallbackAgentWalletToExecuteIntent(resolved, response)) {
|
|
514
|
-
|
|
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);
|
|
515
577
|
if (isEmptyQueuedExecuteIntent(intentResult.raw || intentResult)) {
|
|
516
578
|
intentResult.success = false;
|
|
517
579
|
intentResult.status = 'failed';
|
|
@@ -527,6 +589,33 @@ async function executeManagedWalletRun(ctx, flags, resolved, options) {
|
|
|
527
589
|
return normalizeManagedExecuteResponse(response, { preflight, payload, isRead });
|
|
528
590
|
}
|
|
529
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
|
+
|
|
530
619
|
async function executeSelfRun(ctx, flags, resolved, options) {
|
|
531
620
|
const skipPreflight = Boolean(options?.skipPreflight);
|
|
532
621
|
const blockedReasonCode = String(options?.blockedReasonCode || 'SELF_MODE_REQUIRES_BUILD_TX');
|
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
|
}
|