@dfm-fi/agent 0.2.1967 → 0.2.1969

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.
@@ -22,6 +22,8 @@ import type { VaultListResponse, VaultDetailResponse, ZapV2Status, ZapV2PrepareR
22
22
  export declare class DfmApiClient {
23
23
  private readonly session;
24
24
  private readonly baseUrl;
25
+ /** Keyless writes that have not returned a result yet: base → the key their retry must reuse. */
26
+ private readonly unansweredOps;
25
27
  constructor(config: AgentConfig, session: SiwsSession);
26
28
  private publicRequest;
27
29
  private authRequest;
@@ -37,11 +37,15 @@ function stableStringify(value) {
37
37
  return `{${entries.join(',')}}`;
38
38
  }
39
39
  /**
40
- * The deterministic operation id for ONE keyless write (A7): same tool + same
41
- * args → same id, so a retry after a LOST response names the first attempt
42
- * instead of executing twice. This is what "the same logical operation" means
43
- * here — a genuinely NEW write with byte-identical args is deliberately
44
- * treated as a retry; change an argument to make a new operation.
40
+ * The operation's identity for ONE keyless write (A7): same tool + same args.
41
+ * It is only the BASE of the idempotency key — `executeViaOperatingWallet`
42
+ * adds a per-operation nonce, reused only while the previous identical call
43
+ * has not returned a result (a lost response, a timeout, an error): that is a
44
+ * retry, and the server collapses it. Once a call returns, the next identical
45
+ * call is a NEW operation — pinning the key to the args alone made the server
46
+ * replay a 24 h-old result to state-dependent tools (`rebalance_to_targets`
47
+ * said "already converged" on a fund 10 % off target; a second identical
48
+ * deposit would have returned the first one's signature) — #109.
45
49
  */
46
50
  function operatingOperationKey(tool, args) {
47
51
  const digest = createHash('sha256').update(`${tool}\n${stableStringify(args)}`).digest('hex');
@@ -49,9 +53,13 @@ function operatingOperationKey(tool, args) {
49
53
  }
50
54
  /** Launch-surface label (`launchedVia: 'mcp'` on the API side) — never a credential. */
51
55
  const CLIENT_HEADERS = dfmClientHeaders();
56
+ /** How long an unanswered keyless write keeps its key for a retry. */
57
+ const OPERATION_RETRY_WINDOW_MS = 30 * 60_000;
52
58
  export class DfmApiClient {
53
59
  session;
54
60
  baseUrl;
61
+ /** Keyless writes that have not returned a result yet: base → the key their retry must reuse. */
62
+ unansweredOps = new Map();
55
63
  constructor(config, session) {
56
64
  this.session = session;
57
65
  this.baseUrl = config.apiUrl;
@@ -403,19 +411,31 @@ export class DfmApiClient {
403
411
  * executing a second time (the two-vaults-two-fees bug, A7).
404
412
  */
405
413
  async executeViaOperatingWallet(tool, args) {
414
+ const base = operatingOperationKey(tool, args);
415
+ const now = Date.now();
416
+ let op = this.unansweredOps.get(base);
417
+ if (!op || now - op.at > OPERATION_RETRY_WINDOW_MS) {
418
+ op = { key: `${base}-${randomUUID().slice(0, 8)}`, at: now };
419
+ this.unansweredOps.set(base, op);
420
+ }
406
421
  const first = await this.authRequest('/agent/operating/execute', {
407
422
  method: 'POST',
408
423
  body: JSON.stringify({ tool, args }),
409
- idempotencyKey: operatingOperationKey(tool, args),
424
+ idempotencyKey: op.key,
410
425
  });
411
426
  // Finished inside the server's inline window — the shape this method has
412
- // always returned.
413
- if (!('jobId' in first))
427
+ // always returned. A result came back, so the next identical call is new.
428
+ if (!('jobId' in first)) {
429
+ this.unansweredOps.delete(base);
414
430
  return first;
431
+ }
415
432
  // 202: the write outlives the gateway timeout and is still running. This is
416
433
  // the ONLY correct response to that — retrying the POST would execute the
417
- // write a SECOND time (a duplicate vault, the creation fee paid twice).
418
- return this.pollOperatingJob(first.jobId);
434
+ // write a SECOND time (a duplicate vault, the creation fee paid twice). A
435
+ // poll that throws (failed, or still running) leaves the key for the retry.
436
+ const done = await this.pollOperatingJob(first.jobId);
437
+ this.unansweredOps.delete(base);
438
+ return done;
419
439
  }
420
440
  /**
421
441
  * Poll a 202'd write to completion.
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * operator from a file path (`DFM_AGENT_KEYPAIR_PATH`) or an env-injected
7
7
  * secret (`DFM_AGENT_KEYPAIR_JSON`). The keypair is NEVER passed through the
8
8
  * MCP protocol, tool arguments, or tool results, and is NEVER logged.
9
- * - The protocol wallet (`9GjE…`) must NOT be used here. The agent signs with
9
+ * - The protocol wallet must NOT be used here. The agent signs with
10
10
  * a throwaway test wallet funded with a few dollars of USDC.
11
11
  * - All real-money WRITE tools (deposit / redeem) are GATED behind
12
12
  * `DFM_AGENT_WRITE_ENABLED=true` and default OFF.
@@ -61,6 +61,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
61
61
  import { dirname, join } from 'node:path';
62
62
  import { z } from 'zod';
63
63
  import { loadConfig } from './config.js';
64
+ import { leanTokenRows, MAX_TOKEN_LIMIT } from './token-search.js';
64
65
  import { SiwsSession } from './session.js';
65
66
  import { DfmApiClient } from './api-client.js';
66
67
  import { isKeylessMode, executeAppWalletWrite, KEYLESS_SETUP_HINT, } from './keyless.js';
@@ -317,9 +318,13 @@ server.registerTool('discover_tokens', {
317
318
  'way to obtain a mint address: never type one from memory, and always ' +
318
319
  'resolve a pasted one before using it — tickers collide and impersonation ' +
319
320
  'is routine. DECIMALS ARE NOT COSMETIC: every onchain amount is raw ' +
320
- 'integer units, and a 6-decimal token differs from a 9-decimal one by 1000x.',
321
+ 'integer units, and a 6-decimal token differs from a 9-decimal one by 1000x. ' +
322
+ 'Returns the top `limit` matches (default 10, max 50) as lean rows, with ' +
323
+ '`basketEligibility` — a token missing from the results may simply not be ' +
324
+ 'holdable by a fund (e.g. wSOL, or Token-2022 with non-allowlisted extensions).',
321
325
  inputSchema: {
322
326
  query: z.string().min(1).max(64),
327
+ limit: z.number().int().min(1).max(MAX_TOKEN_LIMIT).optional(),
323
328
  assetClass: z
324
329
  .string()
325
330
  .optional()
@@ -333,9 +338,9 @@ server.registerTool('discover_tokens', {
333
338
  'fun (pump.fun) | stable | ybt (yield-bearing) | rwa (tokenized real-world assets)'),
334
339
  },
335
340
  annotations: { readOnlyHint: true, openWorldHint: true },
336
- }, async ({ query, assetClass }) => {
341
+ }, async ({ query, assetClass, limit }) => {
337
342
  try {
338
- return text(await api.searchTokens(query, assetClass));
343
+ return text(leanTokenRows(await api.searchTokens(query, assetClass), limit));
339
344
  }
340
345
  catch (e) {
341
346
  return errText(`discover_tokens failed: ${e.message}`);
@@ -12,10 +12,10 @@
12
12
  * the app wallet, source = its input-mint ATA, destination = its
13
13
  * output-mint ATA, input amount = the requested amount, slippage ≤
14
14
  * the requested slippage, platform fee ≤ 20 bps to none or DFM's
15
- * (9GjE) ATA of either mint, and a quoted output that — after its
15
+ * (PROTOCOL_WALLET) ATA of either mint, and a quoted output that — after its
16
16
  * own slippage — is at least what an INDEPENDENT Jupiter quote
17
17
  * (fetched by the MCP, never via DFM) gives after the requested
18
- * slippage, minus 0.5% drift · at most one SOL transfer to 9GjE ≤
18
+ * slippage, minus 0.5% drift · at most one SOL transfer to PROTOCOL_WALLET ≤
19
19
  * 0.05 SOL, and only when the route carries no token fee ·
20
20
  * ComputeBudget: ≤ 1 unit limit (≤ 1.4M), ≤ 1 unit price with a
21
21
  * total priority fee ≤ 0.001 SOL, heap frame; nothing else.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `discover_tokens` output shaping (#109): `/assets/search` answers ~100 rows of
3
+ * full Jupiter metadata (90–117 KB per query) — more than an MCP host can put in
4
+ * context, so the one legitimate way to get a mint fell back to a file. The
5
+ * agent needs the identity, the safety signals and whether a fund may hold it.
6
+ */
7
+ export declare const DEFAULT_TOKEN_LIMIT = 10;
8
+ export declare const MAX_TOKEN_LIMIT = 50;
9
+ export declare function leanTokenRows(rows: unknown, limit?: number): unknown;
10
+ //# sourceMappingURL=token-search.d.ts.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `discover_tokens` output shaping (#109): `/assets/search` answers ~100 rows of
3
+ * full Jupiter metadata (90–117 KB per query) — more than an MCP host can put in
4
+ * context, so the one legitimate way to get a mint fell back to a file. The
5
+ * agent needs the identity, the safety signals and whether a fund may hold it.
6
+ */
7
+ export const DEFAULT_TOKEN_LIMIT = 10;
8
+ export const MAX_TOKEN_LIMIT = 50;
9
+ const LEAN_FIELDS = [
10
+ 'mint', 'symbol', 'name', 'decimals', 'isVerified', 'assetClass', 'isToken2022',
11
+ 'liquidityUsd', 'mcap', 'organicScoreLabel', 'basketEligibility', 'eligibilityReason',
12
+ ];
13
+ export function leanTokenRows(rows, limit = DEFAULT_TOKEN_LIMIT) {
14
+ if (!Array.isArray(rows))
15
+ return rows;
16
+ const n = Math.min(Math.max(1, Math.floor(limit)), MAX_TOKEN_LIMIT);
17
+ const out = rows.slice(0, n).map((r) => {
18
+ const row = r;
19
+ const lean = {};
20
+ for (const k of LEAN_FIELDS)
21
+ if (row[k] !== undefined)
22
+ lean[k] = row[k];
23
+ return lean;
24
+ });
25
+ return { results: out, shown: out.length, total: rows.length };
26
+ }
27
+ //# sourceMappingURL=token-search.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfm-fi/agent",
3
- "version": "0.2.1967",
3
+ "version": "0.2.1969",
4
4
  "description": "DFM v2 MCP server — drive the full fund launch → deposit → redeem (+ manage) lifecycle from an AI agent against the live API.",
5
5
  "homepage": "https://app.dfm.finance/docs/agent",
6
6
  "mcpName": "io.github.DFM-Finance/dfm-agent",
@@ -55,7 +55,7 @@
55
55
  "lint": "eslint .",
56
56
  "example:read": "tsx src/examples/list-and-status.ts",
57
57
  "example:lifecycle": "tsx src/examples/launch-deposit-redeem.ts",
58
- "test": "npm run build && node scripts/smoke-signing.mjs && node scripts/smoke-signing-v1.mjs &&node scripts/smoke-cli.mjs && node scripts/smoke-keyless.mjs && node scripts/smoke-zap-options.mjs && tsx src/who-pays.test.ts && tsx src/wallet-identity.test.ts && tsx src/money-path.test.ts && tsx src/control-flow.test.ts && tsx src/create-flow.metadata.test.ts && tsx src/manager-flow.reconfigure.test.ts && tsx src/recovery-flow.deadleg.test.ts && tsx src/zap-v2-flow.pending.test.ts && tsx src/mcp-server.confirm-gate.test.ts && tsx src/app-wallet-owner.test.ts && tsx src/onboarding.test.ts && tsx src/default-avatar.test.ts && tsx src/client-header.test.ts && tsx src/owner-actions.test.ts && tsx src/owner-rebalance.test.ts"
58
+ "test": "npm run build && node scripts/smoke-signing.mjs && node scripts/smoke-signing-v1.mjs &&node scripts/smoke-cli.mjs && node scripts/smoke-keyless.mjs && node scripts/smoke-zap-options.mjs && tsx src/who-pays.test.ts && tsx src/wallet-identity.test.ts && tsx src/money-path.test.ts && tsx src/control-flow.test.ts && tsx src/create-flow.metadata.test.ts && tsx src/manager-flow.reconfigure.test.ts && tsx src/recovery-flow.deadleg.test.ts && tsx src/zap-v2-flow.pending.test.ts && tsx src/mcp-server.confirm-gate.test.ts && tsx src/app-wallet-owner.test.ts && tsx src/onboarding.test.ts && tsx src/default-avatar.test.ts && tsx src/client-header.test.ts && tsx src/owner-actions.test.ts && tsx src/owner-rebalance.test.ts && tsx src/token-search.test.ts"
59
59
  },
60
60
  "dependencies": {
61
61
  "@modelcontextprotocol/sdk": "^1.32.1",