@thirdfy/agent-cli 0.3.0 → 0.4.1
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 +125 -0
- package/README.md +59 -19
- package/package.json +2 -2
- package/scripts/validate-cli-contract-schemas.mjs +6 -0
- package/src/cli/errors.mjs +58 -8
- package/src/cli/help.mjs +1 -1
- package/src/cli/manifest.mjs +62 -6
- package/src/cli/options/bootstrap.mjs +15 -3
- package/src/cli/options/chains.mjs +5 -0
- package/src/cli/options/credits.mjs +9 -0
- package/src/cli/options/data.mjs +13 -1
- package/src/cli/options/index.mjs +10 -4
- package/src/cli/options/walletControl.mjs +8 -0
- package/src/commands/bootstrap.mjs +44 -5
- package/src/commands/chat.mjs +3 -0
- package/src/commands/credits.mjs +109 -16
- package/src/commands/delegation.mjs +3 -1
- package/src/commands/discovery.mjs +156 -13
- package/src/commands/doctor.mjs +5 -2
- package/src/commands/execute.mjs +96 -3
- package/src/commands/prompt.mjs +5 -3
- package/src/commands/wallet.mjs +238 -3
- package/src/commands/walletControl.mjs +66 -0
- package/src/contracts/cli/schemas/actions.response.schema.json +45 -0
- package/src/contracts/cli/schemas/chains-non-evm.response.schema.json +76 -0
- package/src/contracts/cli/schemas/chains.response.schema.json +151 -0
- package/src/contracts/cli/schemas/error.response.schema.json +61 -0
- package/src/contracts/cli/schemas/preview.response.schema.json +179 -0
- package/src/contracts/cli/schemas/readiness.response.schema.json +94 -0
- package/src/contracts/cli/schemas/wallet-profile.response.schema.json +127 -0
- package/src/core/args.mjs +10 -2
- package/src/core/chainRegistry.mjs +223 -1
- package/src/core/context.mjs +57 -14
- package/src/core/errorEnvelope.mjs +209 -0
- package/src/core/fundingTokenIn.mjs +24 -1
- package/src/core/http.mjs +6 -1
- package/src/core/readOnlyActions.mjs +10 -0
- package/src/core/walletExecuteChainId.mjs +25 -0
- package/src/lib/creditsUsdSummary.mjs +48 -4
- package/src/runtime/actions/actionList.mjs +87 -0
- package/src/runtime/actions/selection.mjs +17 -22
- package/src/runtime/execution/actionPreflight.mjs +50 -14
- package/src/runtime/execution/payloads.mjs +36 -28
- package/src/runtime/execution/runners.mjs +75 -21
- package/src/runtime/execution/svmLane.mjs +106 -0
- package/src/runtime/execution/walletControl.mjs +104 -0
- package/src/runtime/handlers.mjs +18 -2
- package/src/runtime/index.mjs +1 -1
- package/src/runtime/main.mjs +21 -5
- package/src/runtime/providerHints/apps.mjs +36 -0
- package/src/runtime/providerHints/catalogHint.mjs +55 -0
- package/src/runtime/providerHints/cex.mjs +130 -0
- package/src/runtime/providerHints/data.mjs +144 -0
- package/src/runtime/providerHints/dispatch.mjs +177 -0
- package/src/runtime/providerHints/dogeos.mjs +38 -0
- package/src/runtime/providerHints/earn.mjs +201 -0
- package/src/runtime/providerHints/perps.mjs +346 -0
- package/src/runtime/providerHints/prediction.mjs +128 -0
- package/src/runtime/providerHints/solana.mjs +85 -0
- package/src/runtime/providerHints/trading.mjs +78 -0
- package/src/runtime/providerHints/walletControl.mjs +39 -0
- package/src/runtime/risk/notional.mjs +59 -0
- package/src/runtime/risk/orderPreview.mjs +154 -0
- package/src/runtime/risk/riskRefusal.mjs +74 -0
- package/src/runtime/validation/actionParamsGate.mjs +2 -0
- package/src/runtime/providerHints.mjs +0 -1017
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
// Chain facts the CLI needs locally: a public RPC for BYOW submit / balance reads, and
|
|
2
|
-
// whether network gas is paid by the sending wallet (no sponsorship on that chain).
|
|
2
|
+
// whether network gas is paid by the sending wallet (no sponsorship on that chain: DogeOS). Thirdfy
|
|
3
|
+
// sponsors Solana network fees; the wallet pays only rent for new token accounts (`chainGasNote`).
|
|
3
4
|
// The API capabilities document stays the source of truth for lane support.
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_CHAIN_ID = 8453;
|
|
6
7
|
|
|
7
8
|
// Only chains whose RPC we have verified. Other chains need --rpc-url or RPC_URL.
|
|
9
|
+
// `chainFamily` defaults to 'evm'. Solana (svm) rows have no rpcUrl: the CLI never signs or
|
|
10
|
+
// broadcasts Solana itself; Solana actions run only on the managed agent_wallet lane.
|
|
8
11
|
export const CHAIN_REGISTRY = {
|
|
9
12
|
8453: { chainKey: 'base-mainnet', displayName: 'Base', rpcUrl: 'https://mainnet.base.org', nativeSymbol: 'ETH' },
|
|
13
|
+
101: {
|
|
14
|
+
chainKey: 'sol-mainnet',
|
|
15
|
+
displayName: 'Solana',
|
|
16
|
+
chainFamily: 'svm',
|
|
17
|
+
caip2: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp',
|
|
18
|
+
nativeSymbol: 'SOL',
|
|
19
|
+
},
|
|
20
|
+
103: {
|
|
21
|
+
chainKey: 'sol-devnet',
|
|
22
|
+
displayName: 'Solana devnet',
|
|
23
|
+
chainFamily: 'svm',
|
|
24
|
+
caip2: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1',
|
|
25
|
+
nativeSymbol: 'SOL',
|
|
26
|
+
},
|
|
10
27
|
6281971: {
|
|
11
28
|
chainKey: 'dogeos-testnet',
|
|
12
29
|
displayName: 'DogeOS Chikyu testnet',
|
|
@@ -28,6 +45,171 @@ export function getChainInfo(chainId) {
|
|
|
28
45
|
return id ? CHAIN_REGISTRY[id] || null : null;
|
|
29
46
|
}
|
|
30
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The chains the Thirdfy API executes on, from `chains` in `GET /api/v1/agent/cli/capabilities`
|
|
50
|
+
* (`{ chainId, chainFamily, caip2, supportedExecutionLanes }`), as thirdfy-mcp
|
|
51
|
+
* `src/config/chainRegistry.ts` reads them. Applied when the CLI negotiates capabilities (no extra
|
|
52
|
+
* request). Until then, or when the API sends no usable list, the defaults below apply.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/** Fallback chain ids when the API sends no chain list (thirdfy-mcp's defaults). */
|
|
56
|
+
export const DEFAULT_EVM_CHAIN_IDS = Object.freeze([8453, 1, 100, 137, 130, 10, 11155420, 42161, 84532, 10200, 480, 4801, 6281971, 4663, 5042002, 5042]);
|
|
57
|
+
export const DEFAULT_SVM_CHAIN_IDS = Object.freeze([101, 103]);
|
|
58
|
+
|
|
59
|
+
let apiChainRegistry;
|
|
60
|
+
|
|
61
|
+
const CAIP2 = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,64}$/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `chainFamily` when the API sends it, else from `caip2` or `tags`, else the built-in table's family
|
|
65
|
+
* (so an entry for 101 with no family info stays Solana), else EVM.
|
|
66
|
+
*/
|
|
67
|
+
function apiChainFamily(entry, chainId) {
|
|
68
|
+
const family = String(entry.chainFamily ?? '').toLowerCase();
|
|
69
|
+
if (family === 'svm' || family === 'evm') return family;
|
|
70
|
+
const caip2 = typeof entry.caip2 === 'string' ? entry.caip2 : '';
|
|
71
|
+
if (caip2.startsWith('solana:')) return 'svm';
|
|
72
|
+
if (caip2.startsWith('eip155:')) return 'evm';
|
|
73
|
+
const tags = Array.isArray(entry.tags) ? entry.tags.map((tag) => String(tag).toLowerCase()) : [];
|
|
74
|
+
if (tags.includes('svm') || tags.includes('solana')) return 'svm';
|
|
75
|
+
return CHAIN_REGISTRY[chainId]?.chainFamily || 'evm';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse the capabilities `chains` list. Undefined for anything without a usable EVM chain: a list
|
|
80
|
+
* the CLI could not run on keeps the defaults rather than refusing every chain.
|
|
81
|
+
*/
|
|
82
|
+
export function parseApiChains(chains) {
|
|
83
|
+
if (!Array.isArray(chains)) return undefined;
|
|
84
|
+
const evm = new Set();
|
|
85
|
+
const svm = new Set();
|
|
86
|
+
const caip2ByChainId = {};
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const entry of chains) {
|
|
89
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
90
|
+
const chainId = Number(entry.chainId);
|
|
91
|
+
if (!Number.isInteger(chainId) || chainId <= 0) continue;
|
|
92
|
+
const chainFamily = apiChainFamily(entry, chainId);
|
|
93
|
+
(chainFamily === 'svm' ? svm : evm).add(chainId);
|
|
94
|
+
const caip2 = typeof entry.caip2 === 'string' && CAIP2.test(entry.caip2) ? entry.caip2 : undefined;
|
|
95
|
+
if (caip2) caip2ByChainId[chainId] = caip2;
|
|
96
|
+
entries.push({
|
|
97
|
+
chainId,
|
|
98
|
+
chainFamily,
|
|
99
|
+
...(caip2 ? { caip2 } : {}),
|
|
100
|
+
...(entry.chainKey ? { chainKey: String(entry.chainKey) } : {}),
|
|
101
|
+
...(entry.displayName ? { displayName: String(entry.displayName) } : {}),
|
|
102
|
+
...(Array.isArray(entry.supportedExecutionLanes) ? { supportedExecutionLanes: entry.supportedExecutionLanes.map(String) } : {}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (evm.size === 0) return undefined;
|
|
106
|
+
return { evmChainIds: [...evm], svmChainIds: [...svm], caip2ByChainId, chains: entries };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Apply a capabilities body's `chains`. A body without a usable list leaves the last one in place. */
|
|
110
|
+
export function applyApiChainRegistry(capabilities) {
|
|
111
|
+
const parsed = parseApiChains(capabilities && typeof capabilities === 'object' ? capabilities.chains : undefined);
|
|
112
|
+
if (!parsed) return false;
|
|
113
|
+
apiChainRegistry = parsed;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The last chain list the API sent, or undefined before the first usable one. */
|
|
118
|
+
export function getApiChainRegistry() {
|
|
119
|
+
return apiChainRegistry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function resetApiChainRegistry() {
|
|
123
|
+
apiChainRegistry = undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Where the chain ids come from: the API's capabilities, or the defaults. */
|
|
127
|
+
export function chainRegistrySource() {
|
|
128
|
+
return apiChainRegistry ? 'api' : 'default';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** EVM chain ids the API runs (the defaults until it says). */
|
|
132
|
+
export function configuredEvmChainIds() {
|
|
133
|
+
return apiChainRegistry ? apiChainRegistry.evmChainIds.slice() : [...DEFAULT_EVM_CHAIN_IDS];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Solana chain ids the API runs: none on an API without Solana (the defaults until it says). */
|
|
137
|
+
export function configuredSvmChainIds() {
|
|
138
|
+
return apiChainRegistry ? apiChainRegistry.svmChainIds.slice() : [...DEFAULT_SVM_CHAIN_IDS];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The CAIP-2 id the API reports for a chain, else the built-in one, else `eip155:<id>` for EVM. */
|
|
142
|
+
export function caip2ForChainId(chainId) {
|
|
143
|
+
const id = positiveChainId(chainId);
|
|
144
|
+
if (!id) return undefined;
|
|
145
|
+
return apiChainRegistry?.caip2ByChainId[id] || getChainInfo(id)?.caip2 || (chainFamilyOf(id) === 'evm' ? `eip155:${id}` : undefined);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* 'svm' for Solana chains, 'evm' for every other chain id (known or not). The API's chain list
|
|
150
|
+
* decides when it names the chain; the built-in table is the fallback.
|
|
151
|
+
*/
|
|
152
|
+
export function chainFamilyOf(chainId) {
|
|
153
|
+
const id = positiveChainId(chainId);
|
|
154
|
+
if (id && apiChainRegistry) {
|
|
155
|
+
if (apiChainRegistry.svmChainIds.includes(id)) return 'svm';
|
|
156
|
+
if (apiChainRegistry.evmChainIds.includes(id)) return 'evm';
|
|
157
|
+
}
|
|
158
|
+
return getChainInfo(chainId)?.chainFamily || 'evm';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Why the API does not run a Solana chain, or undefined when it does (or the list is unknown):
|
|
163
|
+
* `SOLANA_NOT_ENABLED` when it lists no svm chain at all (thirdfy-mcp `unsupportedChainFields`),
|
|
164
|
+
* `CHAIN_NOT_SUPPORTED` when it runs Solana but not this chain id.
|
|
165
|
+
*/
|
|
166
|
+
export function svmChainRefusal(chainId) {
|
|
167
|
+
const id = positiveChainId(chainId);
|
|
168
|
+
if (!id || !apiChainRegistry || chainFamilyOf(id) !== 'svm' || apiChainRegistry.svmChainIds.includes(id)) return undefined;
|
|
169
|
+
return apiChainRegistry.svmChainIds.length === 0 ? 'SOLANA_NOT_ENABLED' : 'CHAIN_NOT_SUPPORTED';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** True for Solana chain ids (101 mainnet, 103 devnet). */
|
|
173
|
+
export function isSvmChainId(chainId) {
|
|
174
|
+
return chainFamilyOf(chainId) === 'svm';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
|
|
178
|
+
// Base58 (no 0, O, I, l), 32-44 chars: a Solana public key.
|
|
179
|
+
const SOLANA_ADDRESS_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
180
|
+
|
|
181
|
+
export function isEvmAddress(value) {
|
|
182
|
+
return EVM_ADDRESS_RE.test(String(value || '').trim());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function isSolanaAddress(value) {
|
|
186
|
+
return SOLANA_ADDRESS_RE.test(String(value || '').trim());
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Wallet address equality. 0x addresses are hex and compare case-insensitively; anything else
|
|
191
|
+
* (Solana base58) is case-sensitive and must match exactly. Never lowercase a base58 address.
|
|
192
|
+
*/
|
|
193
|
+
export function sameWalletAddress(a, b) {
|
|
194
|
+
const left = String(a || '').trim();
|
|
195
|
+
const right = String(b || '').trim();
|
|
196
|
+
if (!left || !right) return false;
|
|
197
|
+
if (/^0x/i.test(left) && /^0x/i.test(right)) return left.toLowerCase() === right.toLowerCase();
|
|
198
|
+
return left === right;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Text for a wallet that cannot pay network gas, when the API sent no nativeGasWarning message.
|
|
203
|
+
*/
|
|
204
|
+
export function describeInsufficientNativeGas(walletAddress, chainId) {
|
|
205
|
+
const info = getChainInfo(chainId);
|
|
206
|
+
const wallet = walletAddress || 'unknown';
|
|
207
|
+
if (info?.chainFamily === 'svm') {
|
|
208
|
+
return `Execution wallet ${wallet} does not hold enough SOL for this write on ${info.displayName} (${positiveChainId(chainId)}). Thirdfy sponsors network fees; the wallet pays rent for new token accounts (about 0.002 SOL each) and any SOL the action spends. Fund it with a little SOL.`;
|
|
209
|
+
}
|
|
210
|
+
return `Execution wallet ${wallet} has 0 native gas on chain ${chainId}; gas on this chain is paid by the wallet.`;
|
|
211
|
+
}
|
|
212
|
+
|
|
31
213
|
/** Chains where the sending wallet pays network gas in the native token (never sponsored). */
|
|
32
214
|
export function chainUserPaysGas(chainId) {
|
|
33
215
|
return Boolean(getChainInfo(chainId)?.userPaysGas);
|
|
@@ -43,6 +225,7 @@ export const USER_PAYS_GAS_CHAIN_IDS = Object.entries(CHAIN_REGISTRY)
|
|
|
43
225
|
*/
|
|
44
226
|
export function describeChainGas(chainId) {
|
|
45
227
|
const info = getChainInfo(chainId);
|
|
228
|
+
if (info?.chainFamily === 'svm') return chainGasNote(chainId).note;
|
|
46
229
|
if (info?.userPaysGas) {
|
|
47
230
|
const faucet = info.faucetUrl ? ` Fund it from ${info.faucetUrl}.` : '';
|
|
48
231
|
return `Network gas on ${info.displayName} (${positiveChainId(chainId)}) is paid in native ${info.nativeSymbol} by the sending wallet; it is not sponsored.${faucet}`;
|
|
@@ -51,6 +234,45 @@ export function describeChainGas(chainId) {
|
|
|
51
234
|
return `Network gas may be sponsored on supported chains, but Thirdfy credits/free-quota gates still apply. On user-pays chains (${userPays}) the sending wallet pays gas in the native token.`;
|
|
52
235
|
}
|
|
53
236
|
|
|
237
|
+
function userPaysGasNote(info) {
|
|
238
|
+
return {
|
|
239
|
+
sponsored: false,
|
|
240
|
+
payer: 'sending_wallet',
|
|
241
|
+
nativeToken: info.nativeSymbol,
|
|
242
|
+
...(info.faucetUrl ? { faucet: info.faucetUrl } : {}),
|
|
243
|
+
note: `${info.displayName} gas is not sponsored: fund the execution wallet with native ${info.nativeSymbol}${
|
|
244
|
+
info.faucetUrl ? ` (faucet: ${info.faucetUrl})` : ''
|
|
245
|
+
} before writes.`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The gas note for a chain whose gas needs a word, as thirdfy-mcp `chainGasNote`
|
|
251
|
+
* (`src/config/chainGas.ts`): a chain whose sending wallet pays gas (DogeOS), or Solana, whose
|
|
252
|
+
* network fees Thirdfy sponsors unless the API's preflight answered `gasSponsored: false` (new token
|
|
253
|
+
* accounts still cost the wallet rent). Undefined for a sponsored EVM chain.
|
|
254
|
+
*/
|
|
255
|
+
export function chainGasNote(chainId, { gasSponsored } = {}) {
|
|
256
|
+
const info = getChainInfo(chainId);
|
|
257
|
+
if (!info) return undefined;
|
|
258
|
+
if (info.chainFamily !== 'svm') return info.userPaysGas ? userPaysGasNote(info) : undefined;
|
|
259
|
+
if (gasSponsored === false) {
|
|
260
|
+
return {
|
|
261
|
+
...userPaysGasNote(info),
|
|
262
|
+
rentPayer: 'execution_wallet',
|
|
263
|
+
note: `${info.displayName} network fees are not sponsored right now: the execution wallet pays fees and rent in SOL. Fund it with a little SOL (about 0.003) before writes.`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
sponsored: true,
|
|
268
|
+
payer: 'thirdfy',
|
|
269
|
+
nativeToken: info.nativeSymbol,
|
|
270
|
+
...(info.faucetUrl ? { faucet: info.faucetUrl } : {}),
|
|
271
|
+
rentPayer: 'execution_wallet',
|
|
272
|
+
note: `Thirdfy sponsors ${info.displayName} network fees: do not ask the user to buy SOL for gas. Fund the wallet with the token the action spends. SOL is needed only for rent when an action opens a new token account (about 0.002 SOL each) and for SOL the action itself spends; a write short of it is refused with INSUFFICIENT_NATIVE_GAS, and only then ask for SOL.`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
54
276
|
/**
|
|
55
277
|
* RPC for a chain. The --rpc-url flag always wins. Then:
|
|
56
278
|
* - Base (or no chain given): BASE_RPC_URL, BASE_RPC_HTTP_URL, RPC_URL, then the registry.
|
package/src/core/context.mjs
CHANGED
|
@@ -120,26 +120,69 @@ export function extractActions(response) {
|
|
|
120
120
|
return [];
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
/**
|
|
124
|
+
* The agent API key from `--agent-api-key`, then THIRDFY_AGENT_API_KEY, then config `agent.apiKey`;
|
|
125
|
+
* empty when none is set. The same order execution uses, without its missing-key error.
|
|
126
|
+
*/
|
|
127
|
+
export function findAgentApiKey(flags = {}) {
|
|
128
|
+
const explicit = String(flags.agentApiKey || '').trim();
|
|
129
|
+
if (explicit) return explicit;
|
|
130
|
+
const envKey = String(process.env.THIRDFY_AGENT_API_KEY || '').trim();
|
|
131
|
+
if (envKey) return envKey;
|
|
132
|
+
return String(loadProfileConfig()?.agent?.apiKey || '').trim();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whose credits a credits read is about. Same precedence as `wallet profile`: an owner credential
|
|
137
|
+
* passed as a flag (--owner-session-token or --auth-token), then an explicit --agent-api-key, then
|
|
138
|
+
* owner auth from env or config, then an agent key from env or config. Returns `{ ledger, agentApiKey,
|
|
139
|
+
* agentApiKeyExplicit }`; `ledger` is `agent` or `owner`.
|
|
140
|
+
*/
|
|
141
|
+
export function resolveCreditsLedger(flags = {}) {
|
|
142
|
+
const config = loadProfileConfig();
|
|
143
|
+
const explicitOwner = Boolean(String(flags.ownerSessionToken || flags.authToken || '').trim());
|
|
144
|
+
const agentApiKeyExplicit = Boolean(String(flags.agentApiKey || '').trim());
|
|
145
|
+
const storedOwner = Boolean(
|
|
146
|
+
String(
|
|
147
|
+
process.env.THIRDFY_OWNER_SESSION_TOKEN || process.env.THIRDFY_AUTH_TOKEN || config?.auth?.ownerSessionToken || config?.auth?.authToken || '',
|
|
148
|
+
).trim(),
|
|
149
|
+
);
|
|
150
|
+
const agentApiKey = findAgentApiKey(flags);
|
|
151
|
+
const agent = !explicitOwner && (agentApiKeyExplicit || (!storedOwner && Boolean(agentApiKey)));
|
|
152
|
+
return { ledger: agent ? 'agent' : 'owner', agentApiKey, agentApiKeyExplicit };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The actions list for this caller, cached per API base and key: the agent's own list
|
|
157
|
+
* (`GET /api/v1/agent/actions`) whenever an agent key is set (flag, env or config, `findAgentApiKey`),
|
|
158
|
+
* else the public catalog. Every caller (`actions`, the params gate, `doctor`) gets the same list,
|
|
159
|
+
* the one execution is checked against. `keyed` says a key was sent; `policyScoped` says the API
|
|
160
|
+
* answered with the policy's allowlist (an unrestricted key gets the whole catalog without it).
|
|
161
|
+
*/
|
|
162
|
+
export async function getCachedActionsCatalogSnapshot(ctx, flags = {}) {
|
|
163
|
+
const agentApiKey = findAgentApiKey(flags);
|
|
164
|
+
const keyed = Boolean(agentApiKey);
|
|
165
|
+
const cacheKey = keyed ? `policy:${ctx.apiBase}:${agentApiKey}` : `public:${ctx.apiBase}`;
|
|
128
166
|
const ttlMs = getCachedActionsCatalogTtlMs();
|
|
129
167
|
const now = Date.now();
|
|
130
168
|
const hit = catalogCache.get(cacheKey);
|
|
131
169
|
if (hit && hit.expiresAt > now) {
|
|
132
|
-
return hit.
|
|
170
|
+
return hit.snapshot;
|
|
133
171
|
}
|
|
134
172
|
// The agent key goes in the `x-agent-api-key` header, never the URL: URLs land in access logs and
|
|
135
173
|
// proxies, and API 3.15 answers a URL key with Deprecation headers.
|
|
136
|
-
const response =
|
|
137
|
-
? await apiGet(ctx, '/api/v1/agent/actions', { 'x-agent-api-key':
|
|
174
|
+
const response = keyed
|
|
175
|
+
? await apiGet(ctx, '/api/v1/agent/actions', { 'x-agent-api-key': agentApiKey })
|
|
138
176
|
: await apiGet(ctx, '/api/v1/agent/actions/catalog');
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
177
|
+
const snapshot = {
|
|
178
|
+
actions: extractActions(response),
|
|
179
|
+
keyed,
|
|
180
|
+
policyScoped: keyed && (response?.policyScoped === true || response?.data?.policyScoped === true),
|
|
181
|
+
};
|
|
182
|
+
catalogCache.set(cacheKey, { snapshot, expiresAt: now + ttlMs });
|
|
183
|
+
return snapshot;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function getCachedActionsCatalog(ctx, flags) {
|
|
187
|
+
return (await getCachedActionsCatalogSnapshot(ctx, flags)).actions;
|
|
145
188
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One set of error fields for every CLI failure, the same as thirdfy-mcp's tool error envelope
|
|
3
|
+
* (`src/mcp/errors/toolErrorEnvelope.ts`, `src/wallet/creditsRefusal.ts`), so an agent reads the
|
|
4
|
+
* same answer from the CLI and the MCP: what went wrong (`errorCode`), whether the same call can
|
|
5
|
+
* succeed later (`retryable`, `retryAfterSeconds`), and what to do (`nextAction`, `hint`).
|
|
6
|
+
*
|
|
7
|
+
* The fields are added next to the CLI's existing error fields; nothing is renamed or removed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Every `nextAction` the CLI sends, and what the caller should do. */
|
|
11
|
+
export const NEXT_ACTIONS = Object.freeze({
|
|
12
|
+
retry_later: 'Nothing changed. Repeat the same call later (after retryAfterSeconds when given).',
|
|
13
|
+
fix_request: 'The request was refused as sent. Change the arguments before trying again.',
|
|
14
|
+
check_credentials: 'The API key or token was refused. Check the agent API key or log in again.',
|
|
15
|
+
check_permissions: 'The credential is valid but may not do this. Ask the owner for the permission.',
|
|
16
|
+
add_credits: "The agent's credits, today's free requests and the owner's credits are used. Check `credits balance`, buy with `credits buy`; do not retry until credits are added.",
|
|
17
|
+
contact_support: 'Thirdfy failed in a way a retry will not fix. Contact Thirdfy support.',
|
|
18
|
+
reread_state_before_retry:
|
|
19
|
+
'A write may have executed. Re-read positions, orders or balances before sending it again.',
|
|
20
|
+
fund_native_gas: "The execution wallet cannot pay this chain's gas. Fund it with the native coin first.",
|
|
21
|
+
owner_reenable_agent_signer: "The wallet owner removed Thirdfy's signer. The owner must re-enable the agent.",
|
|
22
|
+
use_evm_chain: 'This Thirdfy API does not run that chain family (Solana). Use an EVM chain the API lists in `chains`.',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Credits refusal codes the API sends (HTTP 402). */
|
|
26
|
+
export const CREDITS_REFUSAL_CODES = new Set(['DAILY_FREE_LIMIT_EXCEEDED', 'INSUFFICIENT_CREDITS', 'CREDITS_EXHAUSTED']);
|
|
27
|
+
|
|
28
|
+
export const CREDITS_REFUSAL_HINT =
|
|
29
|
+
"Nothing ran: the agent's credits, today's free requests and the owner's credits are all used for this action. Show the balance with `thirdfy-agent credits balance`; free requests reset each UTC day. Buy credits with USDC from the agent's wallet: `thirdfy-agent credits buy --usd <whole dollars>`. Do not retry until credits are added.";
|
|
30
|
+
|
|
31
|
+
export const CREDITS_CLAIM_PENDING_HINT =
|
|
32
|
+
'The credits payment may have been sent but its claim did not finish. Check `thirdfy-agent credits balance` before anything else, and do not buy again.';
|
|
33
|
+
|
|
34
|
+
/** An error code: upper-case words joined by underscores (`AGENT_SIGNER_REMOVED_BY_OWNER`). */
|
|
35
|
+
const ERROR_CODE = /^[A-Z][A-Z0-9_]{2,63}$/;
|
|
36
|
+
/** A next-action token (`retry_later`); API prose goes to `hint` instead. */
|
|
37
|
+
const NEXT_ACTION_TOKEN = /^[a-z][a-z0-9_]{1,63}$/;
|
|
38
|
+
const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
39
|
+
|
|
40
|
+
const STATUS_CODES = Object.freeze({
|
|
41
|
+
400: 'BAD_REQUEST',
|
|
42
|
+
401: 'UNAUTHORIZED',
|
|
43
|
+
402: 'PAYMENT_REQUIRED',
|
|
44
|
+
403: 'FORBIDDEN',
|
|
45
|
+
404: 'NOT_FOUND',
|
|
46
|
+
408: 'UPSTREAM_TIMEOUT',
|
|
47
|
+
409: 'CONFLICT',
|
|
48
|
+
422: 'UNPROCESSABLE',
|
|
49
|
+
429: 'RATE_LIMITED',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** CLI-side refusals whose next step is not "fix the request". */
|
|
53
|
+
const CLIENT_CODE_FIELDS = Object.freeze({
|
|
54
|
+
TIMEOUT: { retryable: true, nextAction: 'retry_later' },
|
|
55
|
+
INSUFFICIENT_NATIVE_GAS: { retryable: false, nextAction: 'fund_native_gas' },
|
|
56
|
+
SOLANA_NOT_ENABLED: { retryable: false, nextAction: 'use_evm_chain' },
|
|
57
|
+
BROADCAST_FAILED: { retryable: false, nextAction: 'reread_state_before_retry' },
|
|
58
|
+
EFFECT_CHECK_FAILED: { retryable: false, nextAction: 'reread_state_before_retry' },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
62
|
+
|
|
63
|
+
const firstString = (...values) => {
|
|
64
|
+
const found = values.find((value) => typeof value === 'string' && value.trim() !== '');
|
|
65
|
+
return found ? found.trim() : undefined;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function codeForStatus(status) {
|
|
69
|
+
if (STATUS_CODES[status]) return STATUS_CODES[status];
|
|
70
|
+
if (status >= 500) return 'UPSTREAM_ERROR';
|
|
71
|
+
if (status >= 400) return 'REQUEST_REFUSED';
|
|
72
|
+
return 'UNKNOWN_ERROR';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function nextActionFor(code, status, retryable) {
|
|
76
|
+
if (retryable) return 'retry_later';
|
|
77
|
+
if (status === 402 || CREDITS_REFUSAL_CODES.has(code)) return 'add_credits';
|
|
78
|
+
if (status === 401 || code === 'UNAUTHORIZED') return 'check_credentials';
|
|
79
|
+
if (status === 403) return 'check_permissions';
|
|
80
|
+
if (status >= 400 && status < 500) return 'fix_request';
|
|
81
|
+
return 'contact_support';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function positiveSeconds(value) {
|
|
85
|
+
const seconds = Number(value);
|
|
86
|
+
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** fetch aborted by the request timer (src/core/http.mjs uses an AbortController). */
|
|
90
|
+
function isTimeout(error) {
|
|
91
|
+
return error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** fetch could not reach the API (DNS, refused, reset): `TypeError: fetch failed` with a cause. */
|
|
95
|
+
function isUnreachable(error) {
|
|
96
|
+
return error?.name === 'TypeError' && (String(error?.message || '') === 'fetch failed' || error?.cause != null);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The body and the bodies nested under its `data`, outermost first. */
|
|
100
|
+
function nestedBodies(body) {
|
|
101
|
+
const bodies = [];
|
|
102
|
+
let current = body;
|
|
103
|
+
for (let depth = 0; depth < 4 && isRecord(current); depth += 1) {
|
|
104
|
+
bodies.push(current);
|
|
105
|
+
current = current.data;
|
|
106
|
+
}
|
|
107
|
+
return bodies;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The credits refusal code a body carries (`errorCode`, `error`, `blockedReason`, at any depth), if any. */
|
|
111
|
+
export function creditsRefusalCode(body) {
|
|
112
|
+
for (const entry of nestedBodies(body)) {
|
|
113
|
+
for (const value of [entry.errorCode, entry.error, entry.blockedReason]) {
|
|
114
|
+
if (typeof value === 'string' && CREDITS_REFUSAL_CODES.has(value)) return value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isCreditsClaimPending(body) {
|
|
121
|
+
return nestedBodies(body).some((entry) =>
|
|
122
|
+
[entry.error, entry.errorCode, entry.blockedReason].some(
|
|
123
|
+
(value) => typeof value === 'string' && value.includes('CREDITS_CLAIM_PENDING'),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function withoutUndefined(fields) {
|
|
129
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `{ errorCode, retryable, retryAfterSeconds?, nextAction, hint? }` for a thrown CLI error.
|
|
134
|
+
* It reads what `src/core/http.mjs` attaches (`statusCode`, `payload`, `write`, `retryAfterSeconds`)
|
|
135
|
+
* and what `createCliError` attaches (`payload.error`, `payload.blockedStage`).
|
|
136
|
+
*/
|
|
137
|
+
export function describeCliError(error) {
|
|
138
|
+
const payload = isRecord(error?.payload) ? error.payload : {};
|
|
139
|
+
const status = Number(error?.statusCode || 0);
|
|
140
|
+
const data = isRecord(payload.data) ? payload.data : {};
|
|
141
|
+
|
|
142
|
+
// A write that timed out or whose claim did not finish: it may have landed.
|
|
143
|
+
const unknownOutcome =
|
|
144
|
+
error?.code === 'WRITE_OUTCOME_UNKNOWN' ||
|
|
145
|
+
[payload.error, payload.errorCode].some((value) => typeof value === 'string' && value.startsWith('WRITE_OUTCOME_UNKNOWN'));
|
|
146
|
+
if (unknownOutcome) {
|
|
147
|
+
return withoutUndefined({
|
|
148
|
+
errorCode: 'WRITE_OUTCOME_UNKNOWN',
|
|
149
|
+
retryable: false,
|
|
150
|
+
nextAction: 'reread_state_before_retry',
|
|
151
|
+
hint: isCreditsClaimPending(payload) ? CREDITS_CLAIM_PENDING_HINT : undefined,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (status) {
|
|
156
|
+
// The specific refusal (`blockedReason`) wins over a generic upper-case `error`.
|
|
157
|
+
const apiCode = [payload.blockedReason, data.blockedReason, payload.errorCode, payload.error, payload.code, data.error].find(
|
|
158
|
+
(value) => typeof value === 'string' && ERROR_CODE.test(value),
|
|
159
|
+
);
|
|
160
|
+
const code = apiCode || creditsRefusalCode(payload) || codeForStatus(status);
|
|
161
|
+
const credits = status === 402 || CREDITS_REFUSAL_CODES.has(code);
|
|
162
|
+
// A 5xx on a write may have landed, unless the API named a deliberate refusal code.
|
|
163
|
+
const mayHaveLanded = error?.write === true && status >= 500 && !apiCode;
|
|
164
|
+
const retryable = mayHaveLanded ? false : RETRYABLE_STATUSES.has(status);
|
|
165
|
+
const after = retryable
|
|
166
|
+
? positiveSeconds(error?.retryAfterSeconds) ?? positiveSeconds(payload.retryAfterSeconds) ?? positiveSeconds(data.retryAfterSeconds)
|
|
167
|
+
: undefined;
|
|
168
|
+
const apiNextAction = firstString(payload.nextAction, data.nextAction);
|
|
169
|
+
const nextActionToken = apiNextAction && NEXT_ACTION_TOKEN.test(apiNextAction) ? apiNextAction : undefined;
|
|
170
|
+
const hint =
|
|
171
|
+
firstString(payload.hint, data.hint, payload.userMessage, data.userMessage, nextActionToken ? undefined : apiNextAction) ||
|
|
172
|
+
(credits ? CREDITS_REFUSAL_HINT : undefined);
|
|
173
|
+
return withoutUndefined({
|
|
174
|
+
errorCode: code,
|
|
175
|
+
retryable,
|
|
176
|
+
retryAfterSeconds: after,
|
|
177
|
+
nextAction: mayHaveLanded ? 'reread_state_before_retry' : nextActionToken || nextActionFor(code, status, retryable),
|
|
178
|
+
hint,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The API never answered (the request was retried once already).
|
|
183
|
+
if (!Object.keys(payload).length && (isTimeout(error) || isUnreachable(error))) {
|
|
184
|
+
return isTimeout(error)
|
|
185
|
+
? { errorCode: 'UPSTREAM_TIMEOUT', retryable: true, nextAction: 'retry_later' }
|
|
186
|
+
: { errorCode: 'UPSTREAM_UNREACHABLE', retryable: true, nextAction: 'retry_later' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A missing or malformed flag (requireFlag, parseJsonFlag in src/core/args.mjs).
|
|
190
|
+
if (error?.usage === true) return { errorCode: 'INVALID_ARGUMENTS', retryable: false, nextAction: 'fix_request' };
|
|
191
|
+
|
|
192
|
+
// A refusal the CLI made itself (`createCliError`) or an unexpected error.
|
|
193
|
+
const clientCode = firstString(payload.error, payload.blockedReason);
|
|
194
|
+
if (clientCode && ERROR_CODE.test(clientCode)) {
|
|
195
|
+
const known = CLIENT_CODE_FIELDS[clientCode] || { retryable: false, nextAction: 'fix_request' };
|
|
196
|
+
return { errorCode: clientCode, ...known };
|
|
197
|
+
}
|
|
198
|
+
return { errorCode: 'INTERNAL_ERROR', retryable: false, nextAction: 'contact_support' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The same fields for a refusal the CLI returns as a result instead of throwing (a blocked
|
|
203
|
+
* preflight, an empty managed read). `blockedReason` is the code.
|
|
204
|
+
*/
|
|
205
|
+
export function blockedResultFields(blockedReason, overrides = {}) {
|
|
206
|
+
const code = String(blockedReason || '').trim();
|
|
207
|
+
const known = CLIENT_CODE_FIELDS[code] || { retryable: false, nextAction: 'fix_request' };
|
|
208
|
+
return { errorCode: code || 'BLOCKED', ...known, ...overrides };
|
|
209
|
+
}
|
|
@@ -24,6 +24,13 @@ const POSITION_EXIT_ACTIONS = new Set([
|
|
|
24
24
|
'dogeos_laika_claim',
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
|
+
/** Selling native SOL passes the wSOL mint; the wallet holds SOL, not a wSOL token account. */
|
|
28
|
+
const SOLANA_WSOL_MINT = 'So11111111111111111111111111111111111111112';
|
|
29
|
+
|
|
30
|
+
/** Solana USDC: what the Solana venue funding actions spend (`amountUsdc`, no mint param). */
|
|
31
|
+
const SOLANA_USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
32
|
+
const SOLANA_USDC_FUNDING_ACTIONS = new Set(['deposit_polymarket_from_solana', 'deposit_hyperliquid_from_solana']);
|
|
33
|
+
|
|
27
34
|
/**
|
|
28
35
|
* Shielded spends pay from the Bermuda pool, not the execution wallet: `private: true` on any action,
|
|
29
36
|
* and every Bermuda write except the public deposit into the pool.
|
|
@@ -33,12 +40,28 @@ function spendsShieldedFunds(params, actionId) {
|
|
|
33
40
|
return actionId.startsWith('bermuda_') && actionId !== 'bermuda_deposit';
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
|
|
43
|
+
/**
|
|
44
|
+
* `spend`: the catalog row's `behavior.spend` (provider app writes) naming the param that holds the
|
|
45
|
+
* token the action spends; checked before the name fallbacks.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveTokenInForFunding(params, action, spend) {
|
|
37
48
|
if (!params || typeof params !== 'object') return '';
|
|
38
49
|
const actionId = normalizeActionKey(action);
|
|
39
50
|
if (spendsShieldedFunds(params, actionId)) return '';
|
|
51
|
+
const declared = spend && spend.tokenParam ? String(params[spend.tokenParam] ?? '').trim() : '';
|
|
52
|
+
if (declared) return declared;
|
|
40
53
|
const explicit = String(params.tokenIn || '').trim();
|
|
41
54
|
if (explicit) return explicit;
|
|
55
|
+
// Solana: the spent SPL mint is the funding token (`inputMint` on a Jupiter swap, `mint` on an SPL
|
|
56
|
+
// transfer). A SOL transfer has no mint; the API's native gas check covers it.
|
|
57
|
+
// wSOL means native SOL here (the action wraps it), so there is no SPL balance to check.
|
|
58
|
+
const inputMint = String(params.inputMint || '').trim();
|
|
59
|
+
if (inputMint) return inputMint === SOLANA_WSOL_MINT ? '' : inputMint;
|
|
60
|
+
if (SOLANA_USDC_FUNDING_ACTIONS.has(actionId)) return SOLANA_USDC_MINT;
|
|
61
|
+
if (actionId === 'solana_transfer') {
|
|
62
|
+
const mint = String(params.mint || '').trim();
|
|
63
|
+
return mint === SOLANA_WSOL_MINT ? '' : mint;
|
|
64
|
+
}
|
|
42
65
|
if (NATIVE_ETH_SPEND_ACTIONS.has(actionId)) return '';
|
|
43
66
|
if (POSITION_EXIT_ACTIONS.has(actionId)) return '';
|
|
44
67
|
if (params.amountEth != null && String(params.amountEth).trim() !== '') return '';
|
package/src/core/http.mjs
CHANGED
|
@@ -151,6 +151,10 @@ async function requestJsonOnce(ctx, route, init) {
|
|
|
151
151
|
const error = new Error(data?.error || data?.message || `HTTP ${response.status}`);
|
|
152
152
|
error.statusCode = response.status;
|
|
153
153
|
error.payload = data;
|
|
154
|
+
// Read by describeCliError (src/core/errorEnvelope.mjs): a 5xx on a write may have landed.
|
|
155
|
+
error.write = !resendAfterTimeoutIsSafe(ctx, init);
|
|
156
|
+
const retryAfter = Number(response.headers?.get?.('retry-after'));
|
|
157
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) error.retryAfterSeconds = Math.ceil(retryAfter);
|
|
154
158
|
throw error;
|
|
155
159
|
}
|
|
156
160
|
return data;
|
|
@@ -169,7 +173,8 @@ export async function requestJson(ctx, route, init) {
|
|
|
169
173
|
if (isTimeoutError(error) && !resendAfterTimeoutIsSafe(ctx, init)) {
|
|
170
174
|
throw writeOutcomeUnknownError(route, ctx, error, init);
|
|
171
175
|
}
|
|
172
|
-
|
|
176
|
+
// A write that got a 5xx may have landed: never resend it (thirdfy-mcp does not either).
|
|
177
|
+
if (attempt === 0 && isRetryableApiError(error) && resendAfterTimeoutIsSafe(ctx, init)) {
|
|
173
178
|
await sleep(250 + Math.floor(Math.random() * 250));
|
|
174
179
|
continue;
|
|
175
180
|
}
|
|
@@ -13,6 +13,9 @@ const READ_ONLY_EXACT = new Set([
|
|
|
13
13
|
'dogeos_barkswap_get_quote',
|
|
14
14
|
'dogeos_laika_read_contract',
|
|
15
15
|
'dogeos_laika_token_state',
|
|
16
|
+
// Planning steps: they check params and return the next actions (thirdfy-api-v2#867, thirdfy-mcp#239).
|
|
17
|
+
'dogeos_prepare_trade',
|
|
18
|
+
'dogeos_prepare_token_launch',
|
|
16
19
|
'basedbid_get_tokens',
|
|
17
20
|
'prepare_polymarket_onboarding',
|
|
18
21
|
'create_polymarket_bridge_deposit',
|
|
@@ -47,8 +50,15 @@ export function isReadOnlyActionMeta(meta) {
|
|
|
47
50
|
return meta.supportsExecute === false && meta.supportsExecuteIntent !== false;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Read vs write for a resolved catalog action: the API's `behavior.readOnly` when the row has it
|
|
55
|
+
* (either way), else the name rules and `isReadOnlyActionMeta`. Provider app actions
|
|
56
|
+
* (thirdfy-app.json) are named freely, so only the catalog knows `partner_vault_apy` is a read.
|
|
57
|
+
* Same rule as thirdfy-mcp `isReadOnlyCatalogAction`.
|
|
58
|
+
*/
|
|
50
59
|
export function isReadOnlyResolvedAction(resolved, options = {}) {
|
|
51
60
|
const meta = resolved?.resolvedActionMeta;
|
|
52
61
|
if (options.requireMeta && (!meta || typeof meta !== 'object')) return false;
|
|
62
|
+
if (typeof meta?.behavior?.readOnly === 'boolean') return meta.behavior.readOnly;
|
|
53
63
|
return isReadOnlyActionName(resolved?.resolvedAction) || isReadOnlyActionMeta(meta);
|
|
54
64
|
}
|