@thirdfy/agent-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +60 -18
  3. package/package.json +2 -2
  4. package/scripts/validate-cli-contract-schemas.mjs +5 -0
  5. package/src/cli/errors.mjs +58 -8
  6. package/src/cli/manifest.mjs +54 -5
  7. package/src/cli/options/bootstrap.mjs +15 -3
  8. package/src/cli/options/chains.mjs +5 -0
  9. package/src/cli/options/credits.mjs +9 -0
  10. package/src/cli/options/data.mjs +13 -1
  11. package/src/cli/options/index.mjs +10 -4
  12. package/src/cli/options/walletControl.mjs +8 -0
  13. package/src/commands/bootstrap.mjs +44 -5
  14. package/src/commands/chat.mjs +3 -0
  15. package/src/commands/credits.mjs +39 -4
  16. package/src/commands/delegation.mjs +3 -1
  17. package/src/commands/discovery.mjs +156 -13
  18. package/src/commands/doctor.mjs +5 -2
  19. package/src/commands/execute.mjs +96 -3
  20. package/src/commands/prompt.mjs +5 -3
  21. package/src/commands/wallet.mjs +119 -3
  22. package/src/commands/walletControl.mjs +66 -0
  23. package/src/contracts/cli/schemas/actions.response.schema.json +45 -0
  24. package/src/contracts/cli/schemas/chains-non-evm.response.schema.json +76 -0
  25. package/src/contracts/cli/schemas/chains.response.schema.json +151 -0
  26. package/src/contracts/cli/schemas/error.response.schema.json +61 -0
  27. package/src/contracts/cli/schemas/preview.response.schema.json +179 -0
  28. package/src/contracts/cli/schemas/readiness.response.schema.json +94 -0
  29. package/src/core/args.mjs +10 -2
  30. package/src/core/chainRegistry.mjs +223 -1
  31. package/src/core/context.mjs +37 -14
  32. package/src/core/errorEnvelope.mjs +209 -0
  33. package/src/core/fundingTokenIn.mjs +24 -1
  34. package/src/core/http.mjs +6 -1
  35. package/src/core/readOnlyActions.mjs +10 -0
  36. package/src/core/walletExecuteChainId.mjs +25 -0
  37. package/src/lib/creditsUsdSummary.mjs +48 -4
  38. package/src/runtime/actions/actionList.mjs +87 -0
  39. package/src/runtime/actions/selection.mjs +17 -22
  40. package/src/runtime/execution/actionPreflight.mjs +50 -14
  41. package/src/runtime/execution/payloads.mjs +36 -28
  42. package/src/runtime/execution/runners.mjs +75 -21
  43. package/src/runtime/execution/svmLane.mjs +106 -0
  44. package/src/runtime/execution/walletControl.mjs +104 -0
  45. package/src/runtime/handlers.mjs +16 -2
  46. package/src/runtime/index.mjs +1 -1
  47. package/src/runtime/main.mjs +21 -5
  48. package/src/runtime/providerHints/apps.mjs +36 -0
  49. package/src/runtime/providerHints/catalogHint.mjs +55 -0
  50. package/src/runtime/providerHints/cex.mjs +130 -0
  51. package/src/runtime/providerHints/data.mjs +144 -0
  52. package/src/runtime/providerHints/dispatch.mjs +177 -0
  53. package/src/runtime/providerHints/dogeos.mjs +38 -0
  54. package/src/runtime/providerHints/earn.mjs +201 -0
  55. package/src/runtime/providerHints/perps.mjs +346 -0
  56. package/src/runtime/providerHints/prediction.mjs +128 -0
  57. package/src/runtime/providerHints/solana.mjs +85 -0
  58. package/src/runtime/providerHints/trading.mjs +78 -0
  59. package/src/runtime/providerHints/walletControl.mjs +39 -0
  60. package/src/runtime/risk/notional.mjs +59 -0
  61. package/src/runtime/risk/orderPreview.mjs +154 -0
  62. package/src/runtime/risk/riskRefusal.mjs +74 -0
  63. package/src/runtime/validation/actionParamsGate.mjs +2 -0
  64. 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.
@@ -120,26 +120,49 @@ export function extractActions(response) {
120
120
  return [];
121
121
  }
122
122
 
123
- export async function getCachedActionsCatalog(ctx, flags) {
124
- const policyAware = Boolean(flags.agentApiKey);
125
- const cacheKey = policyAware
126
- ? `policy:${ctx.apiBase}:${String(flags.agentApiKey)}`
127
- : `public:${ctx.apiBase}`;
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
+ * The actions list for this caller, cached per API base and key: the agent's own list
137
+ * (`GET /api/v1/agent/actions`) whenever an agent key is set (flag, env or config, `findAgentApiKey`),
138
+ * else the public catalog. Every caller (`actions`, the params gate, `doctor`) gets the same list,
139
+ * the one execution is checked against. `keyed` says a key was sent; `policyScoped` says the API
140
+ * answered with the policy's allowlist (an unrestricted key gets the whole catalog without it).
141
+ */
142
+ export async function getCachedActionsCatalogSnapshot(ctx, flags = {}) {
143
+ const agentApiKey = findAgentApiKey(flags);
144
+ const keyed = Boolean(agentApiKey);
145
+ const cacheKey = keyed ? `policy:${ctx.apiBase}:${agentApiKey}` : `public:${ctx.apiBase}`;
128
146
  const ttlMs = getCachedActionsCatalogTtlMs();
129
147
  const now = Date.now();
130
148
  const hit = catalogCache.get(cacheKey);
131
149
  if (hit && hit.expiresAt > now) {
132
- return hit.actions;
150
+ return hit.snapshot;
133
151
  }
134
152
  // The agent key goes in the `x-agent-api-key` header, never the URL: URLs land in access logs and
135
153
  // 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) })
154
+ const response = keyed
155
+ ? await apiGet(ctx, '/api/v1/agent/actions', { 'x-agent-api-key': agentApiKey })
138
156
  : await apiGet(ctx, '/api/v1/agent/actions/catalog');
139
- const actions = extractActions(response);
140
- catalogCache.set(cacheKey, {
141
- actions,
142
- expiresAt: now + ttlMs,
143
- });
144
- return actions;
157
+ const snapshot = {
158
+ actions: extractActions(response),
159
+ keyed,
160
+ policyScoped: keyed && (response?.policyScoped === true || response?.data?.policyScoped === true),
161
+ };
162
+ catalogCache.set(cacheKey, { snapshot, expiresAt: now + ttlMs });
163
+ return snapshot;
164
+ }
165
+
166
+ export async function getCachedActionsCatalog(ctx, flags) {
167
+ return (await getCachedActionsCatalogSnapshot(ctx, flags)).actions;
145
168
  }
@@ -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
- export function resolveTokenInForFunding(params, action) {
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
- if (attempt === 0 && isRetryableApiError(error)) {
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
  }
@@ -1,3 +1,5 @@
1
+ import { isSvmChainId } from './chainRegistry.mjs';
2
+
1
3
  const DEFAULT_CHAIN_ID = 8453;
2
4
 
3
5
  function normalizeActionId(action) {
@@ -47,3 +49,26 @@ export function resolveWalletExecuteChainId({ action, chainId, params } = {}) {
47
49
  }
48
50
  return positiveChainId(chainId) || positiveChainId(record.chainId) || DEFAULT_CHAIN_ID;
49
51
  }
52
+
53
+ /**
54
+ * Chain a catalog row implies when the caller gave none. Only a row that runs on Solana alone
55
+ * (every `supportedChains` entry is svm, e.g. [101]) implies a chain: mainnet 101 when listed,
56
+ * else its first chain. Any other row returns undefined, so the Base default stays.
57
+ */
58
+ /**
59
+ * Chain a catalog row implies when the caller gave none: its `defaultChainId` (the API sends it for
60
+ * single-chain provider app writes), else the Solana-only rule of `inferChainIdFromSupportedChains`.
61
+ * Any other row returns undefined, so the Base default stays.
62
+ */
63
+ export function inferChainIdFromCatalogRow(row) {
64
+ if (!row || typeof row !== 'object') return undefined;
65
+ return positiveChainId(row.defaultChainId) || inferChainIdFromSupportedChains(row.supportedChains);
66
+ }
67
+
68
+ export function inferChainIdFromSupportedChains(supportedChains) {
69
+ if (!Array.isArray(supportedChains) || supportedChains.length === 0) return undefined;
70
+ const ids = supportedChains.map(positiveChainId).filter(Boolean);
71
+ if (ids.length !== supportedChains.length) return undefined;
72
+ if (!ids.every((id) => isSvmChainId(id))) return undefined;
73
+ return ids.includes(101) ? 101 : ids[0];
74
+ }