@balchemyai/agent-sdk 0.1.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 (78) hide show
  1. package/README.md +282 -0
  2. package/dist/agent-loop/agent-loop.d.ts +54 -0
  3. package/dist/agent-loop/agent-loop.d.ts.map +1 -0
  4. package/dist/agent-loop/agent-loop.js +328 -0
  5. package/dist/agent-loop/agent-loop.js.map +1 -0
  6. package/dist/agent-loop/decision-handler.d.ts +37 -0
  7. package/dist/agent-loop/decision-handler.d.ts.map +1 -0
  8. package/dist/agent-loop/decision-handler.js +91 -0
  9. package/dist/agent-loop/decision-handler.js.map +1 -0
  10. package/dist/agent-loop/llm-adapters/anthropic.d.ts +10 -0
  11. package/dist/agent-loop/llm-adapters/anthropic.d.ts.map +1 -0
  12. package/dist/agent-loop/llm-adapters/anthropic.js +60 -0
  13. package/dist/agent-loop/llm-adapters/anthropic.js.map +1 -0
  14. package/dist/agent-loop/llm-adapters/openai.d.ts +11 -0
  15. package/dist/agent-loop/llm-adapters/openai.d.ts.map +1 -0
  16. package/dist/agent-loop/llm-adapters/openai.js +54 -0
  17. package/dist/agent-loop/llm-adapters/openai.js.map +1 -0
  18. package/dist/agent-loop/llm-cost-tracker.d.ts +21 -0
  19. package/dist/agent-loop/llm-cost-tracker.d.ts.map +1 -0
  20. package/dist/agent-loop/llm-cost-tracker.js +63 -0
  21. package/dist/agent-loop/llm-cost-tracker.js.map +1 -0
  22. package/dist/agent-loop/model-router.d.ts +35 -0
  23. package/dist/agent-loop/model-router.d.ts.map +1 -0
  24. package/dist/agent-loop/model-router.js +55 -0
  25. package/dist/agent-loop/model-router.js.map +1 -0
  26. package/dist/agent-loop/telemetry-reporter.d.ts +60 -0
  27. package/dist/agent-loop/telemetry-reporter.d.ts.map +1 -0
  28. package/dist/agent-loop/telemetry-reporter.js +78 -0
  29. package/dist/agent-loop/telemetry-reporter.js.map +1 -0
  30. package/dist/agent-loop/types.d.ts +125 -0
  31. package/dist/agent-loop/types.d.ts.map +1 -0
  32. package/dist/agent-loop/types.js +10 -0
  33. package/dist/agent-loop/types.js.map +1 -0
  34. package/dist/agent-loop/webhook-receiver.d.ts +22 -0
  35. package/dist/agent-loop/webhook-receiver.d.ts.map +1 -0
  36. package/dist/agent-loop/webhook-receiver.js +145 -0
  37. package/dist/agent-loop/webhook-receiver.js.map +1 -0
  38. package/dist/auth/onboarding.d.ts +12 -0
  39. package/dist/auth/onboarding.d.ts.map +1 -0
  40. package/dist/auth/onboarding.js +51 -0
  41. package/dist/auth/onboarding.js.map +1 -0
  42. package/dist/auth/token-store.d.ts +44 -0
  43. package/dist/auth/token-store.d.ts.map +1 -0
  44. package/dist/auth/token-store.js +67 -0
  45. package/dist/auth/token-store.js.map +1 -0
  46. package/dist/client/http-client.d.ts +22 -0
  47. package/dist/client/http-client.d.ts.map +1 -0
  48. package/dist/client/http-client.js +127 -0
  49. package/dist/client/http-client.js.map +1 -0
  50. package/dist/errors/agent-sdk-error.d.ts +13 -0
  51. package/dist/errors/agent-sdk-error.d.ts.map +1 -0
  52. package/dist/errors/agent-sdk-error.js +17 -0
  53. package/dist/errors/agent-sdk-error.js.map +1 -0
  54. package/dist/errors/error-codes.d.ts +2 -0
  55. package/dist/errors/error-codes.d.ts.map +1 -0
  56. package/dist/errors/error-codes.js +3 -0
  57. package/dist/errors/error-codes.js.map +1 -0
  58. package/dist/index.d.ts +34 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +63 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/mcp/mcp-client.d.ts +184 -0
  63. package/dist/mcp/mcp-client.d.ts.map +1 -0
  64. package/dist/mcp/mcp-client.js +469 -0
  65. package/dist/mcp/mcp-client.js.map +1 -0
  66. package/dist/streaming/sse-event-stream.d.ts +58 -0
  67. package/dist/streaming/sse-event-stream.d.ts.map +1 -0
  68. package/dist/streaming/sse-event-stream.js +263 -0
  69. package/dist/streaming/sse-event-stream.js.map +1 -0
  70. package/dist/types.d.ts +151 -0
  71. package/dist/types.d.ts.map +1 -0
  72. package/dist/types.js +3 -0
  73. package/dist/types.js.map +1 -0
  74. package/dist/utils/retry.d.ts +30 -0
  75. package/dist/utils/retry.d.ts.map +1 -0
  76. package/dist/utils/retry.js +85 -0
  77. package/dist/utils/retry.js.map +1 -0
  78. package/package.json +68 -0
package/README.md ADDED
@@ -0,0 +1,282 @@
1
+ # @balchemyai/agent-sdk
2
+
3
+ TypeScript SDK for Balchemy external AI agents. Handles onboarding (SIWE wallet-based or walletless Identity flow), MCP tool access, token lifecycle management, and real-time SSE event streaming.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install @balchemyai/agent-sdk
9
+ ```
10
+
11
+ ## Auth Paths
12
+
13
+ ### Path 1 — SIWE (wallet-based)
14
+
15
+ Use this when your agent controls a Solana or EVM wallet and can sign messages.
16
+
17
+ ```ts
18
+ import { BalchemyAgentSdk } from "@balchemyai/agent-sdk";
19
+
20
+ const sdk = new BalchemyAgentSdk({
21
+ apiBaseUrl: "https://api.balchemy.ai/api",
22
+ });
23
+
24
+ // 1. Request a nonce and SIWS message
25
+ const { message, nonce } = await sdk.requestSiweNonce({
26
+ address: "YOUR_WALLET_ADDRESS",
27
+ chainId: 8453,
28
+ domain: "youragent.example.com",
29
+ uri: "https://youragent.example.com",
30
+ statement: "Sign in to Balchemy",
31
+ });
32
+
33
+ // 2. Sign `message` with your wallet (off-chain, using your signing library)
34
+ const signature = await wallet.signMessage(message);
35
+
36
+ // 3. Onboard
37
+ const response = await sdk.onboardWithSiwe({
38
+ message,
39
+ signature,
40
+ agentId: "your-agent-unique-id",
41
+ scope: "trade", // "read" | "trade"
42
+ });
43
+
44
+ const mcp = sdk.connectMcp({
45
+ endpoint: response.mcp.endpoint,
46
+ apiKey: response.mcp.apiKey ?? "",
47
+ });
48
+ ```
49
+
50
+ ### Path 2 — Identity / Walletless
51
+
52
+ Use this when your agent has an HMAC-signed identity token (for balchemy native) or ES256 JWT (for external providers) from a provider registered with Balchemy.
53
+
54
+ ```ts
55
+ import { BalchemyAgentSdk } from "@balchemyai/agent-sdk";
56
+
57
+ const sdk = new BalchemyAgentSdk({
58
+ apiBaseUrl: "https://api.balchemy.ai/api",
59
+ });
60
+
61
+ const response = await sdk.onboardWithIdentity({
62
+ provider: "your-registered-provider-id",
63
+ identityToken: "YOUR_PROVIDER_JWT",
64
+ agentId: "your-agent-unique-id",
65
+ chainId: 8453,
66
+ scope: "trade",
67
+ });
68
+
69
+ const mcp = sdk.connectMcp({
70
+ endpoint: response.mcp.endpoint,
71
+ apiKey: response.mcp.apiKey ?? "",
72
+ });
73
+ ```
74
+
75
+ ## Using the MCP Client
76
+
77
+ ```ts
78
+ // List available tools
79
+ const { tools } = await mcp.listTools();
80
+
81
+ // Natural language query
82
+ const reply = await mcp.askBot({ message: "What is the price of SOL?" });
83
+
84
+ // Execute an agent instruction
85
+ const result = await mcp.agentExecute({
86
+ instruction: "Find a low-risk setup on Base with 50 USDC",
87
+ });
88
+
89
+ // EVM quote (read-only, no wallet interaction)
90
+ const quote = await mcp.evmQuote({
91
+ chainId: 8453,
92
+ sellToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base
93
+ buyToken: "0x4200000000000000000000000000000000000006", // WETH on Base
94
+ sellAmount: "50000000", // 50 USDC (6 decimals)
95
+ });
96
+
97
+ // EVM swap (submit: false = pending order, submit: true = on-chain execution)
98
+ const swap = await mcp.evmSwap({
99
+ chainId: 8453,
100
+ sellToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
101
+ buyToken: "0x4200000000000000000000000000000000000006",
102
+ sellAmount: "50000000",
103
+ submit: true,
104
+ });
105
+ ```
106
+
107
+ ## Tool Exposure and Scope
108
+
109
+ By default the MCP endpoint exposes **7 tools**: `ask_bot`, `trade_command`, `agent_execute`, `agent_research`, `agent_portfolio`, `agent_status`, `agent_config`.
110
+
111
+ The full catalog of **106 tools** is available when the platform flag `MCP_EXPOSE_GRANULAR_TOOLS=true` is enabled on the bot. Contact the Balchemy team to enable granular tool access for your integration.
112
+
113
+ Tool scopes:
114
+
115
+ | Scope | Access |
116
+ |-------|--------|
117
+ | `"read"` | Read-only tools — market data, portfolio views, research |
118
+ | `"trade"` | All read tools + trade execution tools |
119
+
120
+ Pass `scope` during onboarding to receive an MCP key with the appropriate permissions.
121
+
122
+ ## Error Handling
123
+
124
+ All SDK methods throw `AgentSdkError` on failure.
125
+
126
+ ```ts
127
+ import { AgentSdkError } from "@balchemyai/agent-sdk";
128
+ import type { AgentSdkErrorCode } from "@balchemyai/agent-sdk";
129
+
130
+ try {
131
+ const result = await mcp.agentExecute({ instruction: "..." });
132
+ } catch (err: unknown) {
133
+ if (err instanceof AgentSdkError) {
134
+ const code: AgentSdkErrorCode = err.code;
135
+ // "auth_error" | "policy_error" | "rate_limit" | "provider_auth_error"
136
+ // | "network_error" | "execution_error" | "invalid_response"
137
+ console.error(`[${code}] ${err.message}`, err.details);
138
+ }
139
+ }
140
+ ```
141
+
142
+ ## Tool Response Helpers
143
+
144
+ ```ts
145
+ import { getToolText, parseToolJson, isToolError } from "@balchemyai/agent-sdk";
146
+
147
+ const response = await mcp.agentPortfolio();
148
+
149
+ if (isToolError(response)) {
150
+ console.error("Tool returned an error:", getToolText(response));
151
+ } else {
152
+ const data = parseToolJson(response); // T | null
153
+ console.log(data);
154
+ }
155
+ ```
156
+
157
+ ## Token Management
158
+
159
+ ```ts
160
+ import { TokenStore } from "@balchemyai/agent-sdk";
161
+
162
+ const store = new TokenStore({
163
+ // Called when the stored token nears expiry — return a fresh OnboardingResponse
164
+ refreshFn: async () => {
165
+ return sdk.onboardWithIdentity({ ... });
166
+ },
167
+ });
168
+
169
+ await store.set(response);
170
+ const token = await store.get(); // auto-refreshes if expiry < threshold
171
+ ```
172
+
173
+ ## Identity Access Token
174
+
175
+ The `OnboardingResponse` includes an `identityAccess` field when the platform issues a short-lived access token alongside the MCP key.
176
+
177
+ ```ts
178
+ import type { IdentityAccess } from "@balchemyai/agent-sdk";
179
+
180
+ const access: IdentityAccess | undefined = response.identityAccess;
181
+ if (access) {
182
+ console.log(access.scope); // "read" | "trade"
183
+ console.log(access.expiresAt); // ISO timestamp
184
+ }
185
+ ```
186
+
187
+ ## SSE Event Streaming
188
+
189
+ ```ts
190
+ import { SseEventStream } from "@balchemyai/agent-sdk";
191
+ import type { SseEvent } from "@balchemyai/agent-sdk";
192
+
193
+ const stream = new SseEventStream(
194
+ "https://api.balchemy.ai/api/events",
195
+ response.mcp.apiKey ?? "",
196
+ { reconnectDelayMs: 2000, maxReconnects: 5 }
197
+ );
198
+
199
+ // Async iterator
200
+ for await (const event of stream) {
201
+ const e: SseEvent = event;
202
+ console.log(e.event, e.data);
203
+ }
204
+
205
+ // Or callback-based
206
+ const unsubscribe = stream.subscribe(
207
+ (event) => console.log(event),
208
+ (err) => console.error(err)
209
+ );
210
+ // later: unsubscribe();
211
+ ```
212
+
213
+ ## Identity Token Revocation
214
+
215
+ ```ts
216
+ // Revoke a token by JTI
217
+ await sdk.revokeIdentityToken({ jti: "the-token-jti", ttlSeconds: 86400 });
218
+
219
+ // Check revocation status
220
+ const { revoked } = await sdk.getIdentityTokenRevokeStatus({ jti: "the-token-jti" });
221
+ ```
222
+
223
+ ## Platform Endpoints Reference
224
+
225
+ | Endpoint | Path | Auth |
226
+ |----------|------|------|
227
+ | MCP server | `POST /api/mcp/{publicId}` | `Authorization: Bearer <mcp-api-key>` |
228
+ | SIWE nonce | `POST /api/nest/auth/evm/nonce` | Public |
229
+ | SIWE onboarding | `POST /api/public/erc8004/onboarding/siwe` | Public |
230
+ | Walletless onboarding | `POST /api/public/erc8004/onboarding/identity` | Public |
231
+ | Token revoke | `POST /api/public/erc8004/onboarding/tokens/revoke` | Public |
232
+ | JWKS | `GET /.well-known/jwks.json` | Public |
233
+ | MCP discovery | `GET /.well-known/mcp.json` | Public |
234
+ | Agent directory | `GET /api/nest/agents/verified/page` | Public |
235
+
236
+ > Note: the JWKS endpoint is served at `/.well-known/jwks.json` (root-relative, **not** under `/api`).
237
+
238
+ ## Platform Operator Setup
239
+
240
+ To enable agent onboarding, the following environment variables must be configured on the backend:
241
+
242
+ | Variable | Required | Description |
243
+ |----------|----------|-------------|
244
+ | `AGENT_WALLETLESS_ONBOARDING_ENABLED` | For walletless path | Set to `true` to enable the identity/walletless onboarding endpoint. Default: `false`. |
245
+ | `SIWE_DOMAIN_ALLOWLIST` | For SIWE path | Comma-separated list of allowed domains for SIWE message verification (e.g. `youragent.example.com,localhost`). |
246
+ | `ERC8004_IDENTITY_PROVIDERS` | For walletless path | Comma-separated list of allowed identity provider IDs (e.g. `github-actions,openclaw`). |
247
+ | `AGENT_IDENTITY_ISSUER_PRIVATE_KEY_PEM` | For identity tokens | ES256 private key (PEM or base64) for signing agent identity access tokens. Required if issuing short-lived identity tokens. |
248
+ | `API_URL` | Always | Base API URL used to build MCP endpoint URLs in onboarding responses (e.g. `https://api.balchemy.ai/api`). |
249
+
250
+ ## Getting Started (Quickstart)
251
+
252
+ 1. Install the SDK:
253
+ ```sh
254
+ npm install @balchemyai/agent-sdk
255
+ ```
256
+
257
+ 2. Create an MCP API key for your bot via the Hub UI: `Hub > Your Bot > API Keys > Create Key`.
258
+
259
+ 3. Choose an auth path:
260
+ - **SIWE (wallet-based):** Your agent must control an EVM wallet and can sign messages.
261
+ - **Walletless (identity token):** Your agent has an HMAC-signed identity token (balchemy native) or ES256 JWT (external provider) from a registered provider. The platform operator must set `AGENT_WALLETLESS_ONBOARDING_ENABLED=true`.
262
+
263
+ 4. Run the relevant code example in the [Auth Paths](#auth-paths) section above.
264
+
265
+ 5. Use the returned `mcp.endpoint` and `mcp.apiKey` to make MCP tool calls.
266
+
267
+ 6. The MCP key is scoped — `"read"` for read-only tools, `"trade"` for trade execution tools. Choose at onboarding time via the `scope` parameter.
268
+
269
+ ## Notes
270
+
271
+ - `agent_seed_request` is disabled on the platform. The `requestSeed()` method exists for backward compatibility but always throws a deterministic `AgentSdkError` with code `"execution_error"`.
272
+ - `apiBaseUrl` must include the `/api` path segment and must **not** have a trailing slash.
273
+ - The JWKS endpoint is at `/.well-known/jwks.json` (root-relative). Do not prefix with `/api`.
274
+ - If walletless onboarding returns HTTP 403 with `code: "FEATURE_DISABLED"`, the platform operator needs to set `AGENT_WALLETLESS_ONBOARDING_ENABLED=true`.
275
+
276
+ ## Docs
277
+
278
+ - [Error and retry strategy](docs/error-retry-strategy.md)
279
+ - [Python parity backlog](docs/python-parity-backlog.md)
280
+ - [Partner integration checklist](docs/partner-integration-checklist.md)
281
+ - [Release policy](docs/release-policy.md)
282
+ - Full API reference: [https://balchemy.ai/docs](https://balchemy.ai/docs)
@@ -0,0 +1,54 @@
1
+ import type { AgentLoopConfig, AgentStatus } from './types';
2
+ export declare class AgentLoop {
3
+ private readonly config;
4
+ private readonly sseEndpoint;
5
+ private readonly costTracker;
6
+ private readonly llm;
7
+ private readonly decisionHandler;
8
+ private readonly mcp;
9
+ private readonly modelRouter;
10
+ private readonly telemetry;
11
+ private webhookReceiver;
12
+ private sseStream;
13
+ private unsubscribeSse;
14
+ private status;
15
+ private startedAt;
16
+ private eventsReceived;
17
+ private decisionsExecuted;
18
+ private tradesExecuted;
19
+ private lastEventAt;
20
+ private lastTradeAt;
21
+ private seenTraceIds;
22
+ private portfolioCache;
23
+ private rulesCache;
24
+ /** publicId extracted from the MCP endpoint path (last path segment). */
25
+ private readonly publicId;
26
+ constructor(config: AgentLoopConfig);
27
+ start(): Promise<void>;
28
+ stop(): Promise<void>;
29
+ getStatus(): AgentStatus;
30
+ /**
31
+ * Send a user message to the bot via the ask_bot MCP tool.
32
+ * Parses the JSON envelope returned by the server and extracts the `reply`
33
+ * field if present; otherwise returns the raw text.
34
+ * On network / tool error, calls `config.onError` and returns an error string.
35
+ */
36
+ sendMessage(message: string): Promise<string>;
37
+ private handleEvent;
38
+ private processEvent;
39
+ /**
40
+ * Fetch the agent portfolio via `agent_portfolio` MCP tool.
41
+ * Result is cached for 30 seconds. On failure, returns an empty snapshot
42
+ * so the decision loop continues with degraded context.
43
+ */
44
+ private fetchPortfolio;
45
+ /**
46
+ * Fetch compressed behavior rules from the MCP resource
47
+ * `balchemy://behavior-rules/{publicId}`.
48
+ * Result is cached for 5 minutes. On failure, returns empty string
49
+ * so decisions still proceed without rule context.
50
+ */
51
+ private fetchBehaviorRules;
52
+ private createLlmAdapter;
53
+ }
54
+ //# sourceMappingURL=agent-loop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-loop.d.ts","sourceRoot":"","sources":["../../src/agent-loop/agent-loop.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EACV,eAAe,EACf,WAAW,EAMZ,MAAM,SAAS,CAAC;AAejB,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAiB;IAC7C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAa;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAkB;IAClD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAoB;IACxC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAoB;IAC9C,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,cAAc,CAA6B;IAEnD,OAAO,CAAC,MAAM,CAA8B;IAC5C,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,cAAc,CAAK;IAC3B,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,YAAY,CAAqB;IAEzC,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,UAAU,CAA2B;IAE7C,yEAAyE;IACzE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;gBAEtB,MAAM,EAAE,eAAe;IAuC7B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CtB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAS3B,SAAS,IAAI,WAAW;IAkBxB;;;;;OAKG;IACG,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAoBnD,OAAO,CAAC,WAAW;YAsCL,YAAY;IAmF1B;;;;OAIG;YACW,cAAc;IAmB5B;;;;;OAKG;YACW,kBAAkB;IAmBhC,OAAO,CAAC,gBAAgB;CAuBzB"}
@@ -0,0 +1,328 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AgentLoop = void 0;
4
+ const sse_event_stream_1 = require("../streaming/sse-event-stream");
5
+ const mcp_client_1 = require("../mcp/mcp-client");
6
+ const llm_cost_tracker_1 = require("./llm-cost-tracker");
7
+ const decision_handler_1 = require("./decision-handler");
8
+ const webhook_receiver_1 = require("./webhook-receiver");
9
+ const openai_1 = require("./llm-adapters/openai");
10
+ const anthropic_1 = require("./llm-adapters/anthropic");
11
+ const model_router_1 = require("./model-router");
12
+ const telemetry_reporter_1 = require("./telemetry-reporter");
13
+ const PORTFOLIO_TTL_MS = 30_000; // 30 seconds
14
+ const RULES_TTL_MS = 5 * 60_000; // 5 minutes
15
+ class AgentLoop {
16
+ config;
17
+ sseEndpoint;
18
+ costTracker;
19
+ llm;
20
+ decisionHandler;
21
+ mcp;
22
+ modelRouter;
23
+ telemetry;
24
+ webhookReceiver = null;
25
+ sseStream = null;
26
+ unsubscribeSse = null;
27
+ status = 'stopped';
28
+ startedAt = 0;
29
+ eventsReceived = 0;
30
+ decisionsExecuted = 0;
31
+ tradesExecuted = 0;
32
+ lastEventAt;
33
+ lastTradeAt;
34
+ seenTraceIds = new Set();
35
+ portfolioCache = null;
36
+ rulesCache = null;
37
+ /** publicId extracted from the MCP endpoint path (last path segment). */
38
+ publicId;
39
+ constructor(config) {
40
+ this.config = config;
41
+ this.sseEndpoint = config.sseEndpoint ??
42
+ `${config.mcpEndpoint}/events/sse`;
43
+ // Extract publicId from endpoint (last path segment after filtering empty segments)
44
+ this.publicId = config.mcpEndpoint.split('/').filter(Boolean).pop() ?? '';
45
+ this.costTracker = new llm_cost_tracker_1.LlmCostTracker({
46
+ maxDailyUsd: config.maxDailyLlmCost ?? 5,
47
+ });
48
+ this.llm = this.createLlmAdapter();
49
+ this.decisionHandler = new decision_handler_1.DecisionHandler(this.llm, this.costTracker, {
50
+ maxConsecutiveFailures: config.maxConsecutiveFailures ?? 3,
51
+ });
52
+ this.mcp = (0, mcp_client_1.connectMcp)({
53
+ endpoint: config.mcpEndpoint,
54
+ apiKey: config.apiKey,
55
+ fetchFn: config.mcpFetchFn,
56
+ });
57
+ // ModelRouter activates only when both cheapModel and fullModel are configured.
58
+ if (config.cheapModel && config.fullModel) {
59
+ this.modelRouter = new model_router_1.ModelRouter({
60
+ cheapModel: config.cheapModel,
61
+ fullModel: config.fullModel,
62
+ });
63
+ }
64
+ else {
65
+ this.modelRouter = null;
66
+ }
67
+ // Derive the telemetry endpoint from the MCP endpoint:
68
+ // https://api.balchemy.ai/mcp/abc123 → https://api.balchemy.ai/api/agent-telemetry/abc123
69
+ const telemetryEndpoint = config.mcpEndpoint.replace(/\/mcp\//, '/api/agent-telemetry/');
70
+ this.telemetry = new telemetry_reporter_1.TelemetryReporter(telemetryEndpoint, config.apiKey);
71
+ }
72
+ async start() {
73
+ this.status = 'starting';
74
+ this.startedAt = Date.now();
75
+ this.telemetry.start();
76
+ // Start webhook receiver if configured
77
+ if (this.config.webhookPort && this.config.webhookSecret) {
78
+ this.webhookReceiver = new webhook_receiver_1.WebhookReceiver({
79
+ secret: this.config.webhookSecret,
80
+ port: this.config.webhookPort,
81
+ });
82
+ await this.webhookReceiver.start((event) => this.handleEvent(event));
83
+ }
84
+ // Start SSE stream
85
+ this.sseStream = new sse_event_stream_1.SseEventStream(this.sseEndpoint, this.config.apiKey, {
86
+ maxReconnects: 0, // unlimited
87
+ reconnectDelayMs: 2000,
88
+ maxReconnectDelayMs: 30_000,
89
+ jitterFactor: 0.25,
90
+ });
91
+ this.unsubscribeSse = this.sseStream.subscribe((sseEvent) => {
92
+ const event = {
93
+ id: sseEvent.id,
94
+ type: sseEvent.event,
95
+ data: sseEvent.data,
96
+ timestamp: Date.now(),
97
+ source: 'sse',
98
+ };
99
+ this.handleEvent(event);
100
+ }, (err) => {
101
+ this.config.onError?.(err instanceof Error ? err : new Error(String(err)));
102
+ });
103
+ this.status = 'running';
104
+ this.config.onStatusChange?.(this.getStatus());
105
+ }
106
+ async stop() {
107
+ this.status = 'stopped';
108
+ this.unsubscribeSse?.();
109
+ this.sseStream?.close();
110
+ await this.webhookReceiver?.stop();
111
+ this.telemetry.stop();
112
+ this.config.onStatusChange?.(this.getStatus());
113
+ }
114
+ getStatus() {
115
+ return {
116
+ status: this.status,
117
+ uptime: this.startedAt > 0 ? Date.now() - this.startedAt : 0,
118
+ eventsReceived: this.eventsReceived,
119
+ decisionsExecuted: this.decisionsExecuted,
120
+ tradesExecuted: this.tradesExecuted,
121
+ llmCallsToday: 0,
122
+ llmCostToday: this.costTracker.getTodaySpend(),
123
+ maxDailyLlmCost: this.config.maxDailyLlmCost ?? 5,
124
+ consecutiveLlmFailures: this.decisionHandler.getConsecutiveFailures(),
125
+ lastEventAt: this.lastEventAt,
126
+ lastTradeAt: this.lastTradeAt,
127
+ sseConnected: this.status === 'running',
128
+ webhookActive: this.webhookReceiver !== null,
129
+ };
130
+ }
131
+ /**
132
+ * Send a user message to the bot via the ask_bot MCP tool.
133
+ * Parses the JSON envelope returned by the server and extracts the `reply`
134
+ * field if present; otherwise returns the raw text.
135
+ * On network / tool error, calls `config.onError` and returns an error string.
136
+ */
137
+ async sendMessage(message) {
138
+ try {
139
+ const response = await this.mcp.callTool('ask_bot', { message });
140
+ const text = response.content?.find((c) => c.type === 'text')?.text ?? '';
141
+ try {
142
+ const parsed = JSON.parse(text);
143
+ const reply = parsed['reply'] ?? parsed['text'];
144
+ return typeof reply === 'string' ? reply : text;
145
+ }
146
+ catch {
147
+ return text;
148
+ }
149
+ }
150
+ catch (err) {
151
+ const msg = err instanceof Error ? err.message : String(err);
152
+ this.config.onError?.(new Error(`ask_bot failed: ${msg}`));
153
+ return `Error: ${msg}`;
154
+ }
155
+ }
156
+ handleEvent(event) {
157
+ // Deduplicate across SSE + webhook
158
+ if (event.id && this.seenTraceIds.has(event.id))
159
+ return;
160
+ if (event.id) {
161
+ this.seenTraceIds.add(event.id);
162
+ // Limit set size
163
+ if (this.seenTraceIds.size > 10_000) {
164
+ const first = this.seenTraceIds.values().next().value;
165
+ if (first)
166
+ this.seenTraceIds.delete(first);
167
+ }
168
+ }
169
+ this.eventsReceived++;
170
+ this.lastEventAt = Date.now();
171
+ this.config.onEvent?.(event);
172
+ // Check budget
173
+ if (!this.costTracker.canCallLlm()) {
174
+ if (this.status !== 'budget_exhausted') {
175
+ this.status = 'budget_exhausted';
176
+ this.config.onStatusChange?.(this.getStatus());
177
+ }
178
+ return;
179
+ }
180
+ // Check if decision handler is paused (too many failures)
181
+ if (this.decisionHandler.isPaused()) {
182
+ if (this.status !== 'llm_failing') {
183
+ this.status = 'llm_failing';
184
+ this.config.onStatusChange?.(this.getStatus());
185
+ }
186
+ return;
187
+ }
188
+ // Process asynchronously
189
+ void this.processEvent(event);
190
+ }
191
+ async processEvent(event) {
192
+ try {
193
+ // Fetch portfolio and behavior rules (both cached)
194
+ const [portfolio, compressedRules] = await Promise.all([
195
+ this.fetchPortfolio(),
196
+ this.fetchBehaviorRules(),
197
+ ]);
198
+ // Apply model routing if configured
199
+ let selectedModel = null;
200
+ let modelTier = null;
201
+ if (this.modelRouter) {
202
+ const score = this.modelRouter.score(event);
203
+ selectedModel = this.modelRouter.selectModel(event);
204
+ modelTier = score >= 60 ? 'full' : 'cheap';
205
+ this.decisionHandler.setModel(selectedModel);
206
+ this.telemetry.reportModelRoute({
207
+ eventType: event.type,
208
+ score,
209
+ selectedModel,
210
+ tier: modelTier,
211
+ });
212
+ }
213
+ const llmCallStart = Date.now();
214
+ const decision = await this.decisionHandler.handleEvent(event, {
215
+ compressedRules,
216
+ portfolioValue: portfolio.totalValueSol ?? 0,
217
+ portfolioSummary: portfolio.summary,
218
+ });
219
+ const llmLatencyMs = Date.now() - llmCallStart;
220
+ // Report LLM call metrics — DecisionHandler exposes last call stats via getCostTracker
221
+ const lastCall = this.decisionHandler.getLastCallStats();
222
+ if (lastCall) {
223
+ this.telemetry.reportLlmCall({
224
+ model: lastCall.model,
225
+ inputTokens: lastCall.inputTokens,
226
+ outputTokens: lastCall.outputTokens,
227
+ latencyMs: llmLatencyMs,
228
+ costUsd: lastCall.costUsd,
229
+ });
230
+ }
231
+ if (!decision || decision.action === 'hold')
232
+ return;
233
+ this.decisionsExecuted++;
234
+ this.config.onDecision?.(decision);
235
+ this.telemetry.reportDecision({
236
+ action: decision.action,
237
+ token: decision.token,
238
+ amount: decision.amount,
239
+ confidence: decision.confidence,
240
+ reasoning: decision.reasoning,
241
+ });
242
+ // Execute via MCP
243
+ if (decision.action === 'buy' || decision.action === 'sell') {
244
+ const tradeResponse = await this.mcp.callTool('trade_command', {
245
+ intent: decision.action,
246
+ token: decision.token,
247
+ amount: decision.amount,
248
+ });
249
+ this.tradesExecuted++;
250
+ this.lastTradeAt = Date.now();
251
+ // Fire trade result callback
252
+ const resultText = tradeResponse.content?.find((c) => c.type === 'text')?.text ?? '';
253
+ this.config.onTradeResult?.({
254
+ action: decision.action,
255
+ token: decision.token,
256
+ amount: decision.amount,
257
+ response: resultText,
258
+ });
259
+ }
260
+ }
261
+ catch (err) {
262
+ this.config.onError?.(err instanceof Error ? err : new Error(String(err)));
263
+ }
264
+ }
265
+ /**
266
+ * Fetch the agent portfolio via `agent_portfolio` MCP tool.
267
+ * Result is cached for 30 seconds. On failure, returns an empty snapshot
268
+ * so the decision loop continues with degraded context.
269
+ */
270
+ async fetchPortfolio() {
271
+ const now = Date.now();
272
+ if (this.portfolioCache && (now - this.portfolioCache.fetchedAt) < PORTFOLIO_TTL_MS) {
273
+ return this.portfolioCache.snapshot;
274
+ }
275
+ try {
276
+ const response = await this.mcp.agentPortfolio();
277
+ const parsed = (0, mcp_client_1.parseToolJson)(response);
278
+ const snapshot = parsed ?? {};
279
+ this.portfolioCache = { snapshot, fetchedAt: now };
280
+ return snapshot;
281
+ }
282
+ catch {
283
+ // Graceful degradation — continue with empty snapshot
284
+ this.config.onError?.(new Error('agent_portfolio fetch failed — continuing with empty snapshot'));
285
+ return {};
286
+ }
287
+ }
288
+ /**
289
+ * Fetch compressed behavior rules from the MCP resource
290
+ * `balchemy://behavior-rules/{publicId}`.
291
+ * Result is cached for 5 minutes. On failure, returns empty string
292
+ * so decisions still proceed without rule context.
293
+ */
294
+ async fetchBehaviorRules() {
295
+ const now = Date.now();
296
+ if (this.rulesCache && (now - this.rulesCache.fetchedAt) < RULES_TTL_MS) {
297
+ return this.rulesCache.compressed;
298
+ }
299
+ const uri = `balchemy://behavior-rules/${this.publicId}`;
300
+ try {
301
+ const contents = await this.mcp.readResource(uri);
302
+ const compressed = contents[0]?.text ?? '';
303
+ this.rulesCache = { compressed, fetchedAt: now };
304
+ return compressed;
305
+ }
306
+ catch {
307
+ // Graceful degradation — continue without rule context
308
+ this.config.onError?.(new Error('behavior-rules resource fetch failed — continuing without rules'));
309
+ return '';
310
+ }
311
+ }
312
+ createLlmAdapter() {
313
+ switch (this.config.llmProvider) {
314
+ case 'anthropic':
315
+ return new anthropic_1.AnthropicAdapter(this.config.llmApiKey, this.config.llmModel, this.config.llmTimeoutMs);
316
+ case 'openai':
317
+ return new openai_1.OpenAiAdapter(this.config.llmApiKey, this.config.llmModel, this.config.llmTimeoutMs, this.config.llmBaseUrl);
318
+ case 'custom':
319
+ throw new Error('Custom LLM adapter must be provided via config.llmAdapter');
320
+ default: {
321
+ const exhaustive = this.config.llmProvider;
322
+ throw new Error(`Unknown LLM provider: ${exhaustive}`);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ exports.AgentLoop = AgentLoop;
328
+ //# sourceMappingURL=agent-loop.js.map