@evomap/evolver 1.89.20 → 2.0.0-beta.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 (231) hide show
  1. package/README.md +27 -564
  2. package/bin/evolver-llm-proxy.js +3 -0
  3. package/bin/evolver-mcp.js +2 -0
  4. package/bin/evolver-proxy.js +3 -0
  5. package/bin/evolver.js +4 -0
  6. package/index.js +1 -3586
  7. package/package.json +32 -62
  8. package/CONTRIBUTING.md +0 -19
  9. package/LICENSE +0 -641
  10. package/README.ja-JP.md +0 -521
  11. package/README.ko-KR.md +0 -520
  12. package/README.zh-CN.md +0 -531
  13. package/SKILL.md +0 -365
  14. package/assets/cover.png +0 -0
  15. package/assets/gep/genes.seed.json +0 -496
  16. package/conformance/savings-core/constants.json +0 -30
  17. package/conformance/savings-core/golden-vectors.json +0 -333
  18. package/scripts/a2a_export.js +0 -63
  19. package/scripts/a2a_ingest.js +0 -79
  20. package/scripts/a2a_promote.js +0 -118
  21. package/scripts/analyze_by_skill.js +0 -121
  22. package/scripts/build_binaries.js +0 -479
  23. package/scripts/check-changelog.js +0 -166
  24. package/scripts/extract_log.js +0 -85
  25. package/scripts/generate_history.js +0 -75
  26. package/scripts/gep_append_event.js +0 -96
  27. package/scripts/gep_personality_report.js +0 -234
  28. package/scripts/human_report.js +0 -147
  29. package/scripts/recall-verify-report.js +0 -234
  30. package/scripts/recover_loop.js +0 -61
  31. package/scripts/refresh_stars_badge.js +0 -168
  32. package/scripts/seed-merchants.js +0 -91
  33. package/scripts/skill2recipes.js +0 -118
  34. package/scripts/suggest_version.js +0 -89
  35. package/scripts/validate-modules.js +0 -38
  36. package/scripts/validate-suite.js +0 -78
  37. package/skills/_meta/SKILL.md +0 -41
  38. package/skills/index.json +0 -14
  39. package/src/adapters/claudeCode.js +0 -194
  40. package/src/adapters/codex.js +0 -216
  41. package/src/adapters/cursor.js +0 -91
  42. package/src/adapters/hookAdapter.js +0 -469
  43. package/src/adapters/kiro.js +0 -195
  44. package/src/adapters/opencode.js +0 -326
  45. package/src/adapters/scripts/_lockPaths.js +0 -74
  46. package/src/adapters/scripts/_memoryFiltering.js +0 -35
  47. package/src/adapters/scripts/_runtimePaths.js +0 -440
  48. package/src/adapters/scripts/evolver-session-end.js +0 -321
  49. package/src/adapters/scripts/evolver-session-start.js +0 -587
  50. package/src/adapters/scripts/evolver-signal-detect.js +0 -98
  51. package/src/adapters/scripts/evolver-task-recall.js +0 -173
  52. package/src/atp/atpExecute.js +0 -283
  53. package/src/atp/atpTaskPickup.js +0 -233
  54. package/src/atp/autoBuyer.js +0 -382
  55. package/src/atp/autoDeliver.js +0 -215
  56. package/src/atp/cli.js +0 -354
  57. package/src/atp/cliAutobuyPrompt.js +0 -154
  58. package/src/atp/consumerAgent.js +0 -157
  59. package/src/atp/defaultHandler.js +0 -69
  60. package/src/atp/heartbeatSignalsHandler.js +0 -254
  61. package/src/atp/hubClient.js +0 -317
  62. package/src/atp/index.js +0 -38
  63. package/src/atp/merchantAgent.js +0 -118
  64. package/src/atp/protocol.js +0 -41
  65. package/src/atp/questionComposer.js +0 -133
  66. package/src/atp/serviceHelper.js +0 -92
  67. package/src/canary.js +0 -13
  68. package/src/config.js +0 -351
  69. package/src/evolve/guards.js +0 -1
  70. package/src/evolve/pipeline/collect.js +0 -1
  71. package/src/evolve/pipeline/dispatch.js +0 -1
  72. package/src/evolve/pipeline/enrich.js +0 -1
  73. package/src/evolve/pipeline/hub.js +0 -1
  74. package/src/evolve/pipeline/select.js +0 -1
  75. package/src/evolve/pipeline/signals.js +0 -1
  76. package/src/evolve/utils.js +0 -1
  77. package/src/evolve.js +0 -1
  78. package/src/experiment/agentRunner.js +0 -229
  79. package/src/experiment/cli.js +0 -159
  80. package/src/experiment/comparison.js +0 -233
  81. package/src/experiment/metrics.js +0 -75
  82. package/src/forceUpdate.js +0 -991
  83. package/src/gep/a2a.js +0 -173
  84. package/src/gep/a2aProtocol.js +0 -1
  85. package/src/gep/analyzer.js +0 -35
  86. package/src/gep/antiAbuseTelemetry.js +0 -1
  87. package/src/gep/assetCallLog.js +0 -197
  88. package/src/gep/assetStore.js +0 -723
  89. package/src/gep/assets.js +0 -36
  90. package/src/gep/autoDistillConv.js +0 -1
  91. package/src/gep/autoDistillLlm.js +0 -1
  92. package/src/gep/bridge.js +0 -138
  93. package/src/gep/candidateEval.js +0 -1
  94. package/src/gep/candidates.js +0 -1
  95. package/src/gep/claimNudge.js +0 -121
  96. package/src/gep/cliContracts.js +0 -1190
  97. package/src/gep/contentHash.js +0 -1
  98. package/src/gep/conversationDistiller.js +0 -1
  99. package/src/gep/conversationSniffer.js +0 -1
  100. package/src/gep/crypto.js +0 -1
  101. package/src/gep/curriculum.js +0 -1
  102. package/src/gep/deviceId.js +0 -1
  103. package/src/gep/directoryClient.js +0 -115
  104. package/src/gep/envFingerprint.js +0 -1
  105. package/src/gep/epigenetics.js +0 -1
  106. package/src/gep/execBridge.js +0 -1
  107. package/src/gep/executionTrace.js +0 -291
  108. package/src/gep/explore.js +0 -1
  109. package/src/gep/featureFlags.js +0 -121
  110. package/src/gep/gitOps.js +0 -265
  111. package/src/gep/hash.js +0 -1
  112. package/src/gep/hostErrorClassifier.js +0 -34
  113. package/src/gep/hubFetch.js +0 -1
  114. package/src/gep/hubReview.js +0 -1
  115. package/src/gep/hubSearch.js +0 -1
  116. package/src/gep/hubVerify.js +0 -1
  117. package/src/gep/idleScheduler.js +0 -400
  118. package/src/gep/issueReporter.js +0 -416
  119. package/src/gep/learningSignals.js +0 -1
  120. package/src/gep/llmReview.js +0 -92
  121. package/src/gep/localStateAwareness.js +0 -243
  122. package/src/gep/mailboxTransport.js +0 -119
  123. package/src/gep/memoryGraph.js +0 -1
  124. package/src/gep/memoryGraphAdapter.js +0 -1
  125. package/src/gep/mutation.js +0 -1
  126. package/src/gep/narrativeMemory.js +0 -1
  127. package/src/gep/oauthLogin.js +0 -181
  128. package/src/gep/openPRRegistry.js +0 -1
  129. package/src/gep/paths.js +0 -522
  130. package/src/gep/personality.js +0 -1
  131. package/src/gep/policyCheck.js +0 -1
  132. package/src/gep/portable.js +0 -103
  133. package/src/gep/privacyClient.js +0 -235
  134. package/src/gep/prompt.js +0 -1
  135. package/src/gep/questionGenerator.js +0 -518
  136. package/src/gep/recallInject.js +0 -1
  137. package/src/gep/recallVerifier.js +0 -1
  138. package/src/gep/reflection.js +0 -1
  139. package/src/gep/sanitize.js +0 -264
  140. package/src/gep/savingsCore.js +0 -1
  141. package/src/gep/schemas/capsule.js +0 -170
  142. package/src/gep/schemas/gene.js +0 -154
  143. package/src/gep/schemas/index.js +0 -8
  144. package/src/gep/schemas/protocol.js +0 -51
  145. package/src/gep/schemas/task.js +0 -74
  146. package/src/gep/selector.js +0 -1
  147. package/src/gep/selfPR.js +0 -469
  148. package/src/gep/signals.js +0 -776
  149. package/src/gep/skill2gep.js +0 -1056
  150. package/src/gep/skill2gepAudit.js +0 -303
  151. package/src/gep/skill2recipes.js +0 -511
  152. package/src/gep/skillDistiller.js +0 -1
  153. package/src/gep/skillPublisher.js +0 -358
  154. package/src/gep/solidify.js +0 -1
  155. package/src/gep/strategy.js +0 -1
  156. package/src/gep/taskReceiver.js +0 -575
  157. package/src/gep/tokenSavings.js +0 -1
  158. package/src/gep/trajectoryExport.js +0 -1
  159. package/src/gep/validationReport.js +0 -55
  160. package/src/gep/validator/index.js +0 -411
  161. package/src/gep/validator/reporter.js +0 -210
  162. package/src/gep/validator/sandboxExecutor.js +0 -480
  163. package/src/gep/validator/stakeBootstrap.js +0 -357
  164. package/src/gep/workspaceKeychain.js +0 -1
  165. package/src/ops/cleanup.js +0 -80
  166. package/src/ops/commentary.js +0 -60
  167. package/src/ops/health_check.js +0 -104
  168. package/src/ops/index.js +0 -11
  169. package/src/ops/innovation.js +0 -67
  170. package/src/ops/lifecycle.js +0 -798
  171. package/src/ops/self_repair.js +0 -76
  172. package/src/ops/skills_monitor.js +0 -147
  173. package/src/ops/trigger.js +0 -33
  174. package/src/proxy/clientSettings.js +0 -405
  175. package/src/proxy/envelope.js +0 -59
  176. package/src/proxy/extensions/dmHandler.js +0 -45
  177. package/src/proxy/extensions/sessionHandler.js +0 -141
  178. package/src/proxy/extensions/skillUpdater.js +0 -64
  179. package/src/proxy/extensions/traceControl.js +0 -1
  180. package/src/proxy/index.js +0 -1395
  181. package/src/proxy/inject.js +0 -1
  182. package/src/proxy/lifecycle/manager.js +0 -1568
  183. package/src/proxy/mailbox/state.js +0 -207
  184. package/src/proxy/mailbox/store.js +0 -590
  185. package/src/proxy/router/cache_passthrough.js +0 -26
  186. package/src/proxy/router/features.js +0 -84
  187. package/src/proxy/router/gemini_route.js +0 -154
  188. package/src/proxy/router/messages_route.js +0 -535
  189. package/src/proxy/router/model_router.js +0 -113
  190. package/src/proxy/router/models_route.js +0 -52
  191. package/src/proxy/router/ollama_route.js +0 -103
  192. package/src/proxy/router/responses_route.js +0 -170
  193. package/src/proxy/router/vertex_route.js +0 -110
  194. package/src/proxy/server/http.js +0 -363
  195. package/src/proxy/server/routes.js +0 -558
  196. package/src/proxy/server/settings.js +0 -115
  197. package/src/proxy/sync/engine.js +0 -179
  198. package/src/proxy/sync/inbound.js +0 -211
  199. package/src/proxy/sync/outbound.js +0 -320
  200. package/src/proxy/task/monitor.js +0 -131
  201. package/src/proxy/trace/extractor.js +0 -1
  202. package/src/proxy/trace/usage.js +0 -1
  203. package/src/webui/client/clientJs/assets.js +0 -111
  204. package/src/webui/client/clientJs/bootstrap.js +0 -92
  205. package/src/webui/client/clientJs/common.js +0 -77
  206. package/src/webui/client/clientJs/i18n.js +0 -366
  207. package/src/webui/client/clientJs/index.js +0 -35
  208. package/src/webui/client/clientJs/interactions.js +0 -351
  209. package/src/webui/client/clientJs/overview.js +0 -152
  210. package/src/webui/client/clientJs/personality.js +0 -285
  211. package/src/webui/client/clientJs/pipelines.js +0 -330
  212. package/src/webui/client/indexHtml.js +0 -221
  213. package/src/webui/client/static.js +0 -23
  214. package/src/webui/client/stylesCss.js +0 -639
  215. package/src/webui/client/vendor/README.md +0 -15
  216. package/src/webui/client/vendor/echarts.min.js +0 -45
  217. package/src/webui/index.js +0 -14
  218. package/src/webui/observer/assets.js +0 -146
  219. package/src/webui/observer/index.js +0 -37
  220. package/src/webui/observer/interactions.js +0 -127
  221. package/src/webui/observer/jsonl.js +0 -75
  222. package/src/webui/observer/paths.js +0 -46
  223. package/src/webui/observer/personality.js +0 -43
  224. package/src/webui/observer/pipelineEvents.js +0 -58
  225. package/src/webui/observer/redact.js +0 -63
  226. package/src/webui/observer/runs.js +0 -356
  227. package/src/webui/observer/safety.js +0 -57
  228. package/src/webui/observer/skills.js +0 -70
  229. package/src/webui/observer/status.js +0 -71
  230. package/src/webui/server/http.js +0 -138
  231. package/src/webui/server/routes.js +0 -41
@@ -1,1395 +0,0 @@
1
- 'use strict';
2
-
3
- const { getEvomapPath } = require('../gep/paths');
4
- const { MailboxStore } = require('./mailbox/store');
5
- const { ProxyHttpServer } = require('./server/http');
6
- const { buildRoutes } = require('./server/routes');
7
- const { buildMessagesHandler, canonicalizeForBedrock, supportsAdaptiveThinking } = require('./router/messages_route');
8
- const { ensureEnvelope } = require('./envelope');
9
- const { buildResponsesHandler, buildChatCompletionsHandler } = require('./router/responses_route');
10
- const { buildGeminiHandler } = require('./router/gemini_route');
11
- const { buildModelsHandler } = require('./router/models_route');
12
- const { buildOllamaHandler } = require('./router/ollama_route');
13
- const { buildVertexHandler } = require('./router/vertex_route');
14
- const { SyncEngine } = require('./sync/engine');
15
- const { LifecycleManager } = require('./lifecycle/manager');
16
- const { TaskMonitor } = require('./task/monitor');
17
- const { SkillUpdater } = require('./extensions/skillUpdater');
18
- const { DmHandler } = require('./extensions/dmHandler');
19
- const { SessionHandler } = require('./extensions/sessionHandler');
20
- const { TraceControl } = require('./extensions/traceControl');
21
- const { backfillProxyTraceUploads } = require('./trace/extractor');
22
- const { hubFetch, sanitizeHubResponseForLog } = require('../gep/hubFetch');
23
-
24
- const TRACE_BACKFILL_DRAIN_MAX_PASSES = 8;
25
- const TRACE_BACKFILL_STARTUP_DRAIN_MAX_MS = 250;
26
- const TRACE_BACKFILL_RUNTIME_DRAIN_MAX_MS = 50;
27
-
28
- // Lazy via paths.getEvomapPath() — honors EVOLVER_HOME (#114).
29
- function _defaultDataDir() { return getEvomapPath('mailbox'); }
30
-
31
- const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
32
- const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
33
- const DEFAULT_OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
34
-
35
- function isAllowedOpenAIHostname(hostname) {
36
- const h = String(hostname || '').toLowerCase();
37
- return h === 'api.openai.com' || h.endsWith('.api.openai.com');
38
- }
39
-
40
- function resolveOpenAIBaseUrl(raw, { trustedOverride = false } = {}) {
41
- const value = String(raw || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
42
- if (trustedOverride) return value;
43
-
44
- let parsed;
45
- try {
46
- parsed = new URL(value);
47
- } catch {
48
- throw new Error('[proxy] EVOMAP_OPENAI_BASE_URL is not a valid URL');
49
- }
50
- if (
51
- parsed.protocol !== 'https:'
52
- || !isAllowedOpenAIHostname(parsed.hostname)
53
- || parsed.pathname !== '/v1'
54
- || parsed.username
55
- || parsed.password
56
- || parsed.search
57
- || parsed.hash
58
- ) {
59
- throw new Error('[proxy] EVOMAP_OPENAI_BASE_URL must be an OpenAI https://*.api.openai.com/v1 endpoint');
60
- }
61
- return value;
62
- }
63
-
64
- function makeOpenAIGatewayError(err, fallbackStatus = 502) {
65
- const name = err && err.name ? String(err.name) : '';
66
- const isTimeout = name === 'TimeoutError' || name === 'AbortError';
67
- const out = new Error(isTimeout ? 'openai upstream timed out' : 'openai upstream request failed');
68
- out.statusCode = isTimeout ? 504 : fallbackStatus;
69
- out.cause = err;
70
- return out;
71
- }
72
-
73
- function makeGeminiGatewayError(err, fallbackStatus = 502) {
74
- const name = err && err.name ? String(err.name) : '';
75
- const isTimeout = name === 'TimeoutError' || name === 'AbortError';
76
- const out = new Error(isTimeout ? 'gemini upstream timed out' : 'gemini upstream request failed');
77
- out.statusCode = isTimeout ? 504 : fallbackStatus;
78
- out.cause = err;
79
- return out;
80
- }
81
-
82
- function makeOllamaGatewayError(err, fallbackStatus = 502) {
83
- const name = err && err.name ? String(err.name) : '';
84
- const isTimeout = name === 'TimeoutError' || name === 'AbortError';
85
- const out = new Error(isTimeout ? 'ollama upstream timed out' : 'ollama upstream request failed');
86
- out.statusCode = isTimeout ? 504 : fallbackStatus;
87
- out.cause = err;
88
- return out;
89
- }
90
-
91
- function makeVertexGatewayError(err, fallbackStatus = 502) {
92
- const name = err && err.name ? String(err.name) : '';
93
- const isTimeout = name === 'TimeoutError' || name === 'AbortError';
94
- const out = new Error(isTimeout ? 'vertex upstream timed out' : 'vertex upstream request failed');
95
- out.statusCode = isTimeout ? 504 : fallbackStatus;
96
- out.cause = err;
97
- return out;
98
- }
99
-
100
- // The hub serves asset signal-search as `GET /a2a/assets/search` with query
101
- // params (signals, status, limit, fields, domain); `signals`/`fields` are
102
- // comma-separated lists. The proxy's public contract stays `POST /asset/search`
103
- // with a JSON body, so we translate that body into the hub's query string here.
104
- // Historically assetSearch forwarded as `POST /a2a/assets/search`, which the
105
- // current hub rejects with `route_not_found` (it only matches the GET form).
106
- function buildAssetSearchQuery(body = {}) {
107
- const query = {};
108
- const csv = (v) => (Array.isArray(v) ? v.join(',') : v);
109
- if (body.signals != null) query.signals = csv(body.signals);
110
- if (body.fields != null) query.fields = csv(body.fields);
111
- if (body.status != null) query.status = body.status;
112
- if (body.domain != null) query.domain = body.domain;
113
- if (body.limit != null) query.limit = body.limit;
114
- return query;
115
- }
116
-
117
- // Free-text path: `GET /a2a/assets/semantic-search?q=...` is the hub's vector
118
- // similarity search. Unlike signal-search it takes ONE natural-language query
119
- // string (the hub sanitizes it to <=200 chars) rather than a signal-keyword
120
- // list, so a caller can ask "what asset fits my current situation?" in prose.
121
- // The situation text rides in `q`; type / limit / fields forward the same way.
122
- function buildSemanticSearchQuery(body = {}) {
123
- const query = { q: body.query };
124
- const csv = (v) => (Array.isArray(v) ? v.join(',') : v);
125
- if (body.fields != null) query.fields = csv(body.fields);
126
- if (body.type != null) query.type = body.type;
127
- if (body.limit != null) query.limit = body.limit;
128
- return query;
129
- }
130
-
131
- // Pick the hub endpoint for the proxy's `POST /asset/search` contract. A
132
- // non-empty free-text `query` selects semantic-search (natural-language context
133
- // match); anything else keeps the signal-keyword path byte-for-byte, so every
134
- // existing signals-only caller is unaffected.
135
- function planAssetSearch(body = {}) {
136
- const q = typeof body.query === 'string' ? body.query.trim() : '';
137
- if (q) {
138
- return {
139
- path: '/a2a/assets/semantic-search',
140
- query: buildSemanticSearchQuery({ ...body, query: q }),
141
- };
142
- }
143
- return { path: '/a2a/assets/search', query: buildAssetSearchQuery(body) };
144
- }
145
-
146
- // Asset-search client-side relief. The hub meters /a2a/assets/search per client
147
- // IP, so an entire proxy fleet sharing one egress (plus the operator hitting it
148
- // manually) collapses into a single bucket and 429s itself. We cache identical
149
- // signal searches briefly, collapse concurrent duplicates into one request, and
150
- // honour the hub's Retry-After so we stop hammering — and stop consuming the
151
- // shared bucket — during a rate-limit window. Tunable via env for ops.
152
- const ASSET_SEARCH_CACHE_TTL_MS = Number(process.env.EVOMAP_ASSET_SEARCH_CACHE_TTL_MS) || 30_000;
153
- const ASSET_SEARCH_CACHE_MAX = Number(process.env.EVOMAP_ASSET_SEARCH_CACHE_MAX) || 256;
154
- // How long a cached result may still be served as "stale" while we are in a
155
- // rate-limit cooldown (better to return slightly-old discovery results than to
156
- // fail the caller and fire a doomed request).
157
- const ASSET_SEARCH_STALE_GRACE_MS = 5 * 60_000;
158
-
159
- // Extract a retry delay (ms) from a hub 429 response: prefer the JSON body's
160
- // ms-precision retry_after_ms (buildRateLimitBody), fall back to the RFC
161
- // Retry-After header (seconds). Returns 0 when neither is present.
162
- function parseRetryAfterMs(res, bodyText) {
163
- try {
164
- const body = bodyText ? JSON.parse(bodyText) : null;
165
- const ms = Number(body && body.retry_after_ms);
166
- if (Number.isFinite(ms) && ms > 0) return Math.ceil(ms);
167
- } catch { /* body is not JSON; fall through to the header */ }
168
- const secs = Number(res && res.headers && res.headers.get && res.headers.get('retry-after'));
169
- if (Number.isFinite(secs) && secs > 0) return secs * 1000;
170
- return 0;
171
- }
172
-
173
- class EvoMapProxy {
174
- constructor(opts = {}) {
175
- // evolver#567: default to the canonical Hub URL (config.resolveHubUrl →
176
- // https://evomap.ai, honouring the A2A_HUB_URL / EVOMAP_HUB_URL /
177
- // EVOLVER_DEFAULT_HUB_URL precedence + https enforcement) instead of '',
178
- // so a freshly-launched proxy is Hub-connected out of the box after
179
- // `evolver login` rather than silently staying hub-less/offline (which
180
- // surfaced as 503 "Hub not configured" and node_id: null over MCP).
181
- // opts.hubUrl still overrides everything.
182
- const { resolveHubUrl } = require('../config');
183
- this.hubUrl = (opts.hubUrl || resolveHubUrl()).replace(/\/+$/, '');
184
- this.dataDir = opts.dataDir || opts.dbPath || _defaultDataDir();
185
- this.port = opts.port;
186
- this.logger = opts.logger || console;
187
- this.clientSettings = opts.clientSettings || null;
188
- this._skillPath = opts.skillPath || null;
189
- this._anthropicBaseUrl = (opts.anthropicBaseUrl || process.env.EVOMAP_ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/+$/, '');
190
- this._openaiBaseUrl = String(opts.openaiBaseUrl || process.env.EVOMAP_OPENAI_BASE_URL || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
191
- this._geminiBaseUrl = String(opts.geminiBaseUrl || process.env.EVOMAP_GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, '');
192
- this._ollamaBaseUrl = String(opts.ollamaBaseUrl || process.env.EVOMAP_OLLAMA_BASE_URL || DEFAULT_OLLAMA_BASE_URL).replace(/\/+$/, '');
193
- this._openaiBaseUrlTrusted = !!opts.openaiBaseUrl;
194
-
195
- this.store = null;
196
- this.server = null;
197
- this.sync = null;
198
- this.lifecycle = null;
199
- this.taskMonitor = null;
200
- this.skillUpdater = null;
201
- this.dmHandler = null;
202
- this.sessionHandler = null;
203
- this.traceControl = null;
204
- this._traceBackfillDraining = false;
205
- this._started = false;
206
-
207
- // Asset-search relief state (see ASSET_SEARCH_* constants above).
208
- this._searchCache = new Map(); // key -> { value, expiresAt, staleUntil }
209
- this._searchInflight = new Map(); // key -> Promise (concurrent dedup)
210
- this._searchCooldownUntil = 0; // epoch ms; >now means hub rate-limited us
211
- }
212
-
213
- async start() {
214
- if (this._started) throw new Error('Proxy already started');
215
-
216
- this.store = new MailboxStore(this.dataDir);
217
-
218
- this.lifecycle = new LifecycleManager({
219
- hubUrl: this.hubUrl,
220
- store: this.store,
221
- logger: this.logger,
222
- getTaskMeta: () => this.taskMonitor ? this.taskMonitor.getHeartbeatMeta() : {},
223
- });
224
-
225
- this.taskMonitor = new TaskMonitor({
226
- store: this.store,
227
- logger: this.logger,
228
- });
229
-
230
- this.skillUpdater = new SkillUpdater({
231
- store: this.store,
232
- skillPath: this._skillPath,
233
- logger: this.logger,
234
- });
235
-
236
- this.dmHandler = new DmHandler({
237
- store: this.store,
238
- logger: this.logger,
239
- });
240
-
241
- this.sessionHandler = new SessionHandler({
242
- store: this.store,
243
- logger: this.logger,
244
- });
245
-
246
- this.traceControl = new TraceControl({
247
- store: this.store,
248
- logger: this.logger,
249
- });
250
- try { this.traceControl.pollAndApply(); } catch (e) {
251
- this.logger?.warn?.('[proxy] traceControl initial poll failed:', e.message);
252
- }
253
-
254
- const getHeaders = () => this.lifecycle._buildHeaders();
255
- const taskMonitor = this.taskMonitor;
256
-
257
- this.sync = new SyncEngine({
258
- store: this.store,
259
- hubUrl: this.hubUrl,
260
- getHeaders,
261
- logger: this.logger,
262
- onAuthError: () => this.lifecycle.reAuthenticate(),
263
- onOutboundFlushed: () => this._drainProxyTraceBackfill({
264
- maxMs: TRACE_BACKFILL_RUNTIME_DRAIN_MAX_MS,
265
- }),
266
- onInboundReceived: () => {
267
- try { this.skillUpdater?.pollAndApply(); } catch (e) {
268
- this.logger?.warn?.('[proxy] skillUpdater.pollAndApply failed:', e.message);
269
- }
270
- try { this.traceControl?.pollAndApply(); } catch (e) {
271
- this.logger?.warn?.('[proxy] traceControl.pollAndApply failed:', e.message);
272
- }
273
- },
274
- });
275
-
276
- const proxyHandlers = {
277
- // /a2a/fetch and /a2a/validate are strict GEP-A2A protocol endpoints:
278
- // the hub runs isValidProtocolMessage and rejects bare bodies
279
- // ({asset_ids: [...]}) with 400 invalid_protocol_message, so wrap them
280
- // in an envelope first. The GET search endpoints below are lenient REST
281
- // and take plain query params -- no envelope there.
282
- assetFetch: (body) => this._proxyHttp('/a2a/fetch', this._wrapA2a('fetch', body)),
283
- // GET (not POST). planAssetSearch() picks signal-search vs semantic-search
284
- // by whether the body carries a free-text `query` or a `signals` list.
285
- assetSearch: (body) => this._assetSearch(body),
286
- assetValidate: (body) => this._proxyHttp('/a2a/validate', this._wrapA2a('validate', body)),
287
- assetPublish: (body) => this._assetPublish(body),
288
- // Reuse-attribution report -> hub /a2a/memory/record. FLAT body (not an
289
- // envelope): the record endpoint reads sender_id/signals/used_asset_ids at
290
- // the top level, so this goes through _reportReuse (like the lenient REST
291
- // search path), not _wrapA2a.
292
- reportReuse: (body) => this._reportReuse(body),
293
- // ATP passthrough (#460 Bug 2): merchant/consumer flows that used to call
294
- // hub directly via src/atp/hubClient.js must route through the proxy when
295
- // EVOMAP_PROXY=1 so proxy sees the transaction (for audit + offline queue).
296
- atpPost: (endpoint, body) => this._proxyHttp(endpoint, body),
297
- atpGet: (endpoint, query) => this._proxyHttp(endpoint, null, { method: 'GET', query }),
298
- };
299
-
300
- const messagesHandler = buildMessagesHandler({
301
- // Provider dispatch: EVOMAP_UPSTREAM read per-request (matches the
302
- // hot-swap policy used for ANTHROPIC_API_KEY at line 266 below).
303
- // Default 'anthropic' keeps the existing path byte-for-byte; 'bedrock'
304
- // forwards via AWS Bedrock InvokeModel/InvokeModelWithResponseStream
305
- // and re-emits standard SSE so the client contract is unchanged.
306
- anthropicProxy: (reqPath, body, opts) => {
307
- // Mode is decided once per request in messages_route.js (the same
308
- // place the auth gate reads it), then passed in via opts.upstreamMode.
309
- // This makes the gate decision and the routing decision share one
310
- // env read, so a hot-swap of EVOMAP_UPSTREAM mid-request can't make
311
- // them disagree (e.g. gate skipped but request still hits Anthropic).
312
- const mode = opts?.upstreamMode || 'anthropic';
313
- return mode === 'bedrock'
314
- ? this._proxyBedrock(reqPath, body, opts)
315
- : this._proxyAnthropic(reqPath, body, opts);
316
- },
317
- logger: this.logger,
318
- traceStore: this.store,
319
- onTraceQueued: () => this.sync?.notifyNewOutbound(),
320
- });
321
- const responsesHandler = buildResponsesHandler({
322
- openAIProxy: (reqPath, body, opts) => this._proxyOpenAIResponses(reqPath, body, opts),
323
- logger: this.logger,
324
- traceStore: this.store,
325
- onTraceQueued: () => this.sync?.notifyNewOutbound(),
326
- });
327
- const geminiHandler = buildGeminiHandler({
328
- geminiProxy: (reqPath, body, opts) => this._proxyGemini(reqPath, body, opts),
329
- logger: this.logger,
330
- traceStore: this.store,
331
- onTraceQueued: () => this.sync?.notifyNewOutbound(),
332
- });
333
- const chatCompletionsHandler = buildChatCompletionsHandler({
334
- openAIProxy: (reqPath, body, opts) => this._proxyOpenAIResponses(reqPath, body, opts),
335
- logger: this.logger,
336
- traceStore: this.store,
337
- onTraceQueued: () => this.sync?.notifyNewOutbound(),
338
- });
339
- const modelsHandler = buildModelsHandler({
340
- // Models list goes straight to the native upstream (never bedrock — /v1/models is an Anthropic/OpenAI
341
- // API concept), so call the provider methods directly rather than the EVOMAP_UPSTREAM-aware closure.
342
- anthropicProxy: (reqPath, body, opts) => this._proxyAnthropic(reqPath, body, opts),
343
- openAIProxy: (reqPath, body, opts) => this._proxyOpenAIResponses(reqPath, body, opts),
344
- logger: this.logger,
345
- });
346
- const ollamaProxy = (reqPath, body, opts) => this._proxyOllama(reqPath, body, opts);
347
- const ollamaTraceOpts = { logger: this.logger, traceStore: this.store, onTraceQueued: () => this.sync?.notifyNewOutbound() };
348
- const ollamaChatHandler = buildOllamaHandler({ ollamaProxy, apiPath: '/api/chat', ...ollamaTraceOpts });
349
- const ollamaGenerateHandler = buildOllamaHandler({ ollamaProxy, apiPath: '/api/generate', ...ollamaTraceOpts });
350
- const vertexHandler = buildVertexHandler({
351
- vertexProxy: (reqPath, body, opts) => this._proxyVertex(reqPath, body, opts),
352
- logger: this.logger,
353
- traceStore: this.store,
354
- onTraceQueued: () => this.sync?.notifyNewOutbound(),
355
- });
356
-
357
- const routes = buildRoutes(this.store, proxyHandlers, this.taskMonitor, {
358
- dmHandler: this.dmHandler,
359
- skillUpdater: this.skillUpdater,
360
- sessionHandler: this.sessionHandler,
361
- getHubMailboxStatus: () => this._getHubMailboxStatus(),
362
- messagesHandler,
363
- responsesHandler,
364
- geminiHandler,
365
- chatCompletionsHandler,
366
- modelsHandler,
367
- ollamaChatHandler,
368
- ollamaGenerateHandler,
369
- vertexHandler,
370
- });
371
-
372
- const OUTBOUND_ROUTES = [
373
- 'POST /mailbox/send',
374
- 'POST /asset/submit',
375
- 'POST /task/claim',
376
- 'POST /task/complete',
377
- 'POST /task/subscribe',
378
- 'POST /task/unsubscribe',
379
- 'POST /dm/send',
380
- 'POST /session/create',
381
- 'POST /session/join',
382
- 'POST /session/leave',
383
- 'POST /session/message',
384
- 'POST /session/delegate',
385
- 'POST /session/submit',
386
- ];
387
- for (const key of OUTBOUND_ROUTES) {
388
- const original = routes[key];
389
- if (!original) continue;
390
- routes[key] = async (ctx) => {
391
- const result = await original(ctx);
392
- this.sync.notifyNewOutbound();
393
- return result;
394
- };
395
- }
396
-
397
- this.server = new ProxyHttpServer(routes, {
398
- port: this.port,
399
- logger: this.logger,
400
- clientSettings: this.clientSettings,
401
- });
402
-
403
- const serverInfo = await this.server.start();
404
-
405
- if (this.hubUrl) {
406
- await this.lifecycle.hello();
407
- this.lifecycle.startHeartbeatLoop();
408
- this.sync.start();
409
- } else {
410
- this.logger.warn('[proxy] No A2A_HUB_URL set, running in offline/local mode');
411
- }
412
-
413
- this._drainProxyTraceBackfill({ maxMs: TRACE_BACKFILL_STARTUP_DRAIN_MAX_MS });
414
-
415
- this._started = true;
416
-
417
- return {
418
- url: serverInfo.url,
419
- port: serverInfo.port,
420
- nodeId: this.lifecycle.nodeId,
421
- };
422
- }
423
-
424
- _runProxyTraceBackfillPass() {
425
- try {
426
- return backfillProxyTraceUploads({
427
- store: this.store,
428
- logger: this.logger,
429
- });
430
- } catch (e) {
431
- this.logger.warn('[proxy] trace backfill failed:', e && e.message ? e.message : e);
432
- return { queued: 0, reasons: { thrown: 1 } };
433
- }
434
- }
435
-
436
- _drainProxyTraceBackfill({
437
- maxPasses = TRACE_BACKFILL_DRAIN_MAX_PASSES,
438
- maxMs = TRACE_BACKFILL_RUNTIME_DRAIN_MAX_MS,
439
- } = {}) {
440
- if (this._traceBackfillDraining) return { queued: 0, passes: 0, deferred: true };
441
- this._traceBackfillDraining = true;
442
- const started = Date.now();
443
- const total = {
444
- queued: 0,
445
- scanned: 0,
446
- skipped: 0,
447
- duplicates: 0,
448
- passes: 0,
449
- reasons: {},
450
- };
451
- try {
452
- for (let i = 0; i < maxPasses; i++) {
453
- const stats = this._runProxyTraceBackfillPass();
454
- total.passes += 1;
455
- total.queued += stats.queued || 0;
456
- total.scanned += stats.scanned || 0;
457
- total.skipped += stats.skipped || 0;
458
- total.duplicates += stats.duplicates || 0;
459
- for (const [reason, count] of Object.entries(stats.reasons || {})) {
460
- total.reasons[reason] = (total.reasons[reason] || 0) + count;
461
- }
462
- const madeProgress = (stats.scanned || 0) > 0
463
- || (stats.queued || 0) > 0
464
- || (stats.skipped || 0) > 0
465
- || (stats.duplicates || 0) > 0;
466
- if (!madeProgress) break;
467
- if (stats.reasons?.max_pending_uploads || stats.reasons?.max_enqueue_bytes
468
- || stats.reasons?.collection_disabled
469
- || stats.reasons?.missing_file || stats.reasons?.missing_store
470
- || stats.reasons?.read_failed || stats.reasons?.thrown) {
471
- break;
472
- }
473
- if (Date.now() - started >= maxMs) break;
474
- }
475
- } finally {
476
- this._traceBackfillDraining = false;
477
- }
478
- if (total.queued > 0) {
479
- this.logger.log('[proxy] queued ' + total.queued + ' existing proxy trace upload(s)');
480
- this.sync?.notifyNewOutbound();
481
- }
482
- return total;
483
- }
484
-
485
- async stop() {
486
- if (!this._started) return;
487
- // Tear down in deliberate reverse-of-start order, but don't let one
488
- // failing step abort the rest: a thrown sync.stop() must not leave the
489
- // HTTP server and store leaked. Each step is isolated; failures are
490
- // warned and collected so shutdown always completes.
491
- const steps = [
492
- ['sync', () => this.sync?.stop()],
493
- ['heartbeat', () => this.lifecycle?.stopHeartbeatLoop()],
494
- ['server', () => this.server?.stop()],
495
- ['store', () => this.store?.close()],
496
- ];
497
- const errors = [];
498
- for (const [name, fn] of steps) {
499
- try {
500
- await fn();
501
- } catch (err) {
502
- errors.push(err);
503
- this.logger.warn('[proxy] error stopping ' + name + ': ' + (err && err.message ? err.message : err));
504
- }
505
- }
506
- this._started = false;
507
- if (errors.length) {
508
- this.logger.log('[proxy] stopped with ' + errors.length + ' teardown error(s)');
509
- } else {
510
- this.logger.log('[proxy] stopped');
511
- }
512
- }
513
-
514
- get mailbox() {
515
- return this.store;
516
- }
517
-
518
- // Wrap a bare body in a GEP-A2A envelope (pass-through if already one),
519
- // signing it with this proxy's node_id as sender_id so callers cannot
520
- // impersonate another node through the proxy.
521
- _wrapA2a(messageType, body) {
522
- return ensureEnvelope(messageType, body, this.store.getState('node_id'));
523
- }
524
-
525
- async _proxyHttp(path, body, opts = {}) {
526
- if (!this.hubUrl) throw Object.assign(new Error('Hub not configured'), { statusCode: 503 });
527
-
528
- const method = (opts.method || 'POST').toUpperCase();
529
- const query = opts.query && typeof opts.query === 'object' ? opts.query : null;
530
- const timeoutMs = opts.timeoutMs || 30_000;
531
-
532
- let fullPath = path;
533
- if (query) {
534
- const qs = new URLSearchParams();
535
- for (const [k, v] of Object.entries(query)) {
536
- if (v !== undefined && v !== null) qs.set(k, String(v));
537
- }
538
- const qsString = qs.toString();
539
- if (qsString) fullPath += (path.includes('?') ? '&' : '?') + qsString;
540
- }
541
-
542
- const endpoint = `${this.hubUrl}${fullPath}`;
543
- const init = {
544
- method,
545
- headers: this.lifecycle._buildHeaders(),
546
- signal: AbortSignal.timeout(timeoutMs),
547
- };
548
- if (method !== 'GET' && method !== 'HEAD') {
549
- init.body = JSON.stringify(body || {});
550
- }
551
-
552
- const res = await hubFetch(endpoint, init);
553
-
554
- if (res.status === 403 || res.status === 401) {
555
- const recovered = await this.lifecycle.reAuthenticate();
556
- if (recovered) {
557
- const retryInit = {
558
- method,
559
- headers: this.lifecycle._buildHeaders(),
560
- signal: AbortSignal.timeout(timeoutMs),
561
- };
562
- if (method !== 'GET' && method !== 'HEAD') {
563
- retryInit.body = JSON.stringify(body || {});
564
- }
565
- const retry = await hubFetch(endpoint, retryInit);
566
- if (!retry.ok) {
567
- const text = await retry.text().catch(() => '');
568
- throw Object.assign(new Error(`Hub ${retry.status}: ${sanitizeHubResponseForLog(text)}`), { statusCode: retry.status });
569
- }
570
- return retry.json();
571
- }
572
- const text = await res.text().catch(() => '');
573
- throw Object.assign(new Error(`Hub ${res.status} (re-auth failed): ${sanitizeHubResponseForLog(text)}`), { statusCode: res.status });
574
- }
575
-
576
- if (!res.ok) {
577
- const text = await res.text().catch(() => '');
578
- const err = Object.assign(new Error(`Hub ${res.status}: ${sanitizeHubResponseForLog(text)}`), { statusCode: res.status });
579
- if (res.status === 429) err.retryAfterMs = parseRetryAfterMs(res, text);
580
- throw err;
581
- }
582
-
583
- return res.json();
584
- }
585
-
586
- // Build the hub asset-search plan with this node's identity attached, so the
587
- // hub's bulkFetchGuard / per-node metering attributes the call to the right
588
- // node instead of treating every fleet member as one anonymous IP. As with
589
- // _wrapA2a, we always stamp the proxy's OWN node_id (never a caller-supplied
590
- // one) so a client cannot attribute its searches to another node through the
591
- // proxy. node_id is advisory; the hub still gates on the Bearer node_secret.
592
- _planAssetSearchWithNode(body) {
593
- const plan = planAssetSearch(body);
594
- const nodeId = this.store && this.store.getState && this.store.getState('node_id');
595
- if (nodeId) plan.query = { ...plan.query, node_id: nodeId };
596
- return plan;
597
- }
598
-
599
- // Stable cache key: same path + same (order-independent) query params.
600
- _assetSearchCacheKey(plan) {
601
- const q = plan.query || {};
602
- const stable = Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&');
603
- return `${plan.path}?${stable}`;
604
- }
605
-
606
- _cacheSearchResult(key, value, now) {
607
- // Bound memory: Map preserves insertion order, so the first key is oldest.
608
- if (this._searchCache.size >= ASSET_SEARCH_CACHE_MAX && !this._searchCache.has(key)) {
609
- const oldest = this._searchCache.keys().next().value;
610
- if (oldest !== undefined) this._searchCache.delete(oldest);
611
- }
612
- this._searchCache.delete(key); // re-insert to refresh recency
613
- this._searchCache.set(key, {
614
- value,
615
- expiresAt: now + ASSET_SEARCH_CACHE_TTL_MS,
616
- staleUntil: now + ASSET_SEARCH_CACHE_TTL_MS + ASSET_SEARCH_STALE_GRACE_MS,
617
- });
618
- }
619
-
620
- // Asset search with client-side relief: fresh-cache short-circuit, concurrent
621
- // dedup, and Retry-After-aware cooldown. Preserves the original return shape
622
- // and the 429 error contract so existing callers (and their "proceed on local
623
- // evidence" fallback) are unaffected.
624
- // Report which fetched assets the agent reused, so the hub credits their
625
- // authors (reuse-reward attribution). Unlike fetch/validate (strict GEP-A2A
626
- // envelopes), the hub's /a2a/memory/record reads a FLAT top-level body
627
- // ({sender_id, signals, status, used_asset_ids}); envelope-wrapping would bury
628
- // those under .payload and the record would 400. The proxy reports as its OWN
629
- // node -- the same node that fetched the asset -- so the hub's buildAttribution
630
- // finds the matching AssetFetcher row (cross-owner + GDI verified hub-side).
631
- // Declaration model, never server-inferred. Best-effort: a report failure
632
- // (insufficient credits, hub error) must never break the agent's session.
633
- // Kill-switch: EVOLVER_PROXY_REPORT_REUSE=0.
634
- async _reportReuse(body) {
635
- if (process.env.EVOLVER_PROXY_REPORT_REUSE === '0') {
636
- return { ok: false, reason: 'report_reuse_disabled' };
637
- }
638
- const nodeId = this.store.getState('node_id');
639
- if (!nodeId) return { ok: false, reason: 'no_node_id' };
640
-
641
- const b = body || {};
642
- const used = Array.isArray(b.used_asset_ids)
643
- ? b.used_asset_ids.filter((x) => typeof x === 'string' && x.length > 0 && x.length <= 200).slice(0, 50)
644
- : [];
645
- if (used.length === 0) return { ok: false, reason: 'no_used_asset_ids' };
646
-
647
- let signals = Array.isArray(b.signals)
648
- ? b.signals.filter((s) => typeof s === 'string' && s.length > 0).slice(0, 32)
649
- : [];
650
- if (signals.length === 0) signals = ['reused_via_mcp'];
651
-
652
- const flat = {
653
- sender_id: nodeId,
654
- signals,
655
- status: b.status === 'failed' ? 'failed' : 'success',
656
- used_asset_ids: used,
657
- ...(typeof b.score === 'number' ? { score: b.score } : {}),
658
- };
659
-
660
- try {
661
- const res = await this._proxyHttp('/a2a/memory/record', flat);
662
- return { ok: true, recorded: res && res.recorded, used_asset_ids: used };
663
- } catch (err) {
664
- this.logger?.warn?.(`[proxy] report-reuse failed: ${err.message}`);
665
- return { ok: false, reason: err.message, statusCode: err.statusCode };
666
- }
667
- }
668
-
669
- async _assetSearch(body) {
670
- const plan = this._planAssetSearchWithNode(body);
671
- const key = this._assetSearchCacheKey(plan);
672
- const now = Date.now();
673
-
674
- // Fresh cache hit — skip the network entirely.
675
- const cached = this._searchCache.get(key);
676
- if (cached && cached.expiresAt > now) return cached.value;
677
-
678
- // Cooldown set by a prior 429: don't fire (it would only fail and burn more
679
- // of the shared bucket). Serve stale within grace if we have it; otherwise
680
- // surface a 429-shaped error so the caller's fallback kicks in with no
681
- // wasted round-trip.
682
- if (now < this._searchCooldownUntil) {
683
- if (cached && cached.staleUntil > now) return cached.value;
684
- throw Object.assign(new Error('Hub 429: rate_limited (client cooldown)'), {
685
- statusCode: 429,
686
- retryAfterMs: this._searchCooldownUntil - now,
687
- fromCooldown: true,
688
- });
689
- }
690
-
691
- // Collapse concurrent identical searches into one in-flight request.
692
- const inflight = this._searchInflight.get(key);
693
- if (inflight) return inflight;
694
-
695
- const p = (async () => {
696
- try {
697
- const value = await this._proxyHttp(plan.path, null, { method: 'GET', query: plan.query });
698
- this._cacheSearchResult(key, value, Date.now());
699
- return value;
700
- } catch (err) {
701
- if (err && err.statusCode === 429) {
702
- const retryAfterMs = Number(err.retryAfterMs) > 0 ? Number(err.retryAfterMs) : ASSET_SEARCH_CACHE_TTL_MS;
703
- this._searchCooldownUntil = Date.now() + retryAfterMs;
704
- if (this.logger && this.logger.warn) {
705
- this.logger.warn(`[proxy] asset search rate-limited by hub; cooling down ${Math.ceil(retryAfterMs / 1000)}s`);
706
- }
707
- // Prefer serving stale over failing the caller during cooldown.
708
- const stale = this._searchCache.get(key);
709
- if (stale && stale.staleUntil > Date.now()) return stale.value;
710
- }
711
- throw err;
712
- } finally {
713
- this._searchInflight.delete(key);
714
- }
715
- })();
716
-
717
- this._searchInflight.set(key, p);
718
- return p;
719
- }
720
-
721
- // Cross-agent / MCP publish. The legacy `evolver_publish_asset` path queued
722
- // an `asset_submit` mailbox message, but the Hub gated that off
723
- // (A2A_MAILBOX_ASSET_SUBMIT_ENABLED) and now enforces signed Gene+Capsule
724
- // bundles on POST /a2a/publish. Route loose asset submits through
725
- // buildPublishBundle so publishing actually reaches the Hub (single-asset
726
- // publish is rejected; the Hub quarantines new bundles for safety review).
727
- async _assetPublish(body) {
728
- const a2a = require('../gep/a2aProtocol');
729
- const nodeId = this.store && this.store.getState && this.store.getState('node_id');
730
- const rawAssets = Array.isArray(body.assets) ? body.assets : (body.asset ? [body.asset] : []);
731
- if (rawAssets.length === 0) {
732
- throw Object.assign(new Error('assets is required'), { statusCode: 400 });
733
- }
734
- const results = [];
735
- for (const raw of rawAssets) {
736
- try {
737
- const { gene, capsule } = this._buildBundleFromLooseAsset(raw);
738
- const msg = a2a.buildPublishBundle({ gene, capsule, nodeId });
739
- const res = await this._proxyHttp('/a2a/publish', msg);
740
- results.push({ ok: true, gene_asset_id: gene.asset_id, capsule_asset_id: capsule.asset_id, response: res });
741
- } catch (err) {
742
- results.push({ ok: false, error: err.message, statusCode: err.statusCode });
743
- }
744
- }
745
- return { published: results.filter(r => r.ok).length, total: results.length, results };
746
- }
747
-
748
- // Build a Hub-valid Gene+Capsule bundle from a loose MCP asset
749
- // ({type, content, summary, signals}). The Hub quality-gates genes (strategy
750
- // >=2 steps >=15 chars; sandboxable `node -e` validation) and requires a
751
- // companion capsule carrying substantive content; fill the structural
752
- // defaults and map the caller's content into strategy/content. Caller-
753
- // supplied strategy/validation/category/outcome win when present.
754
- _buildBundleFromLooseAsset(raw) {
755
- const crypto = require('crypto');
756
- const { SCHEMA_VERSION } = require('../gep/contentHash');
757
- const r = raw || {};
758
- const text = String(r.content || r.summary || '').trim();
759
- const signals = (Array.isArray(r.signals) && r.signals.length) ? r.signals
760
- : (Array.isArray(r.signals_match) && r.signals_match.length) ? r.signals_match : ['user_request'];
761
- // Gene strategy: the Hub requires >=2 actionable steps, each >=15 chars.
762
- // Enforce upfront (clean 400) rather than letting the Hub reject the publish.
763
- // Bugbot #256: caller-supplied short steps and a sub-50-char capsule content
764
- // previously slipped past the proxy and got rejected at the Hub instead.
765
- let strategy;
766
- if (Array.isArray(r.strategy) && r.strategy.length) {
767
- strategy = r.strategy.map((s) => String(s).trim()).filter((s) => s.length >= 15);
768
- if (strategy.length < 2) {
769
- throw Object.assign(new Error('publish: `strategy` needs >=2 steps, each >=15 chars (Hub quality gate).'), { statusCode: 400 });
770
- }
771
- } else {
772
- const steps = text.split(/[.\n;]+/).map((s) => s.trim()).filter((s) => s.length >= 15);
773
- if (steps.length >= 2) {
774
- strategy = steps.slice(0, 8);
775
- } else if (text.length >= 50) {
776
- strategy = [text.slice(0, 200), 'Validate the result before adopting the change'];
777
- } else {
778
- throw Object.assign(new Error('publish: provide `content` (>=50 chars) or a `strategy` of >=2 steps (each >=15 chars); the Hub quality-gates published genes.'), { statusCode: 400 });
779
- }
780
- }
781
- const VALID_CATEGORIES = ['repair', 'optimize', 'innovate', 'explore'];
782
- const schemaVersion = r.schema_version || SCHEMA_VERSION;
783
- const gid = r.gene_id || ('mcp_g_' + crypto.randomBytes(6).toString('hex'));
784
- const summary = r.summary || text.slice(0, 120) || 'manually published asset';
785
- // Capsule needs >=50 chars of substance (Hub gate); guarantee it upfront.
786
- const capsuleContent = text.length >= 50 ? text : (summary + ' — ' + strategy.join(' ')).trim();
787
- if (capsuleContent.length < 50) {
788
- throw Object.assign(new Error('publish: capsule content resolves to <50 chars; provide a longer `content` or `summary`.'), { statusCode: 400 });
789
- }
790
- const gene = {
791
- type: 'Gene', schema_version: schemaVersion, id: gid,
792
- category: VALID_CATEGORIES.includes(r.category) ? r.category : 'explore',
793
- summary,
794
- signals_match: signals,
795
- strategy,
796
- constraints: (r.constraints && typeof r.constraints === 'object') ? r.constraints : { max_files: 50, forbidden_paths: [] },
797
- validation: (Array.isArray(r.validation) && r.validation.length) ? r.validation : ['node -e "if (![1].length) process.exit(1)"'],
798
- };
799
- const capsule = {
800
- type: 'Capsule', schema_version: schemaVersion, id: 'mcp_c_' + crypto.randomBytes(6).toString('hex'),
801
- trigger: signals, gene: gid, summary,
802
- confidence: typeof r.confidence === 'number' ? r.confidence : 0.5,
803
- blast_radius: { files: 1, lines: 1 },
804
- env_fingerprint: { platform: process.platform, arch: process.arch },
805
- outcome: (r.outcome && typeof r.outcome === 'object' && r.outcome.status) ? r.outcome : { status: 'success', score: 0.5 },
806
- content: capsuleContent,
807
- };
808
- // Redact PII/secrets before publish (Bugbot #256 High). Without client-side
809
- // sanitize, the Hub's server-side redaction rewrites the body and recomputes
810
- // a divergent asset_id, causing persistent roundtrip_missing. Sanitize before
811
- // buildPublishBundle stamps asset_id. Mirrors skill2gep's publish path.
812
- const { sanitizePayload } = require('../gep/sanitize');
813
- return { gene: sanitizePayload(gene), capsule: sanitizePayload(capsule) };
814
- }
815
-
816
- // Phase C slice 4 + token mediation: relay to api.anthropic.com. The
817
- // route layer applies router rewrite and decides stream vs. JSON; this
818
- // method forwards the request and exposes the response shape.
819
- //
820
- // Allowed forward headers (lowercased): x-api-key, anthropic-version,
821
- // and anything matching anthropic-* (anthropic-beta, etc.). Everything
822
- // else (host, authorization, cookie, content-length, ...) is dropped
823
- // so the inbound proxy-auth header never leaks upstream.
824
- //
825
- // Token mediation: the proxy server's `Authorization: Bearer <token>`
826
- // header is consumed by ProxyHttpServer for self-auth and stripped
827
- // here, so clients (e.g. Claude Code) can authenticate to the proxy
828
- // with `ANTHROPIC_AUTH_TOKEN=<proxy_token>` without losing the ability
829
- // to reach Anthropic upstream. When the client did not pass x-api-key,
830
- // the proxy substitutes its own EVOMAP_ANTHROPIC_API_KEY /
831
- // ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env var on the upstream request.
832
- // Env is read per-request so creds
833
- // can be hot-swapped without restart, matching the EVOMAP_MODEL_*
834
- // policy in README.
835
- async _proxyAnthropic(reqPath, body, opts = {}) {
836
- const injectedUpstreamBaseUrl = process.env.EVOMAP_PROXY_AUTO_INJECTED === '1'
837
- ? process.env.EVOMAP_ANTHROPIC_BASE_URL
838
- : '';
839
- const baseUrl = (opts.baseUrl || injectedUpstreamBaseUrl || this._anthropicBaseUrl || '').replace(/\/+$/, '');
840
- const inbound = opts.inboundHeaders || {};
841
- const timeoutMs = opts.timeoutMs || 60_000;
842
-
843
- const fwd = { 'content-type': 'application/json' };
844
- for (const [k, v] of Object.entries(inbound)) {
845
- if (v === undefined || v === null) continue;
846
- const lk = k.toLowerCase();
847
- if (lk === 'x-api-key' || lk === 'anthropic-version' || lk.startsWith('anthropic-')) {
848
- fwd[lk] = Array.isArray(v) ? v.join(', ') : String(v);
849
- }
850
- }
851
-
852
- if (!fwd['x-api-key']) {
853
- const upstreamApiKey = process.env.EVOMAP_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY;
854
- if (upstreamApiKey) {
855
- fwd['x-api-key'] = upstreamApiKey;
856
- } else {
857
- const upstreamAuthToken = process.env.EVOMAP_ANTHROPIC_AUTH_TOKEN
858
- || (process.env.EVOMAP_PROXY_AUTO_INJECTED === '1' ? '' : process.env.ANTHROPIC_AUTH_TOKEN);
859
- if (upstreamAuthToken) {
860
- fwd['authorization'] = `Bearer ${upstreamAuthToken}`;
861
- }
862
- }
863
- }
864
-
865
- const endpoint = `${baseUrl}${reqPath}`;
866
- const method = (opts.method || 'POST').toUpperCase();
867
- const init = { method, headers: fwd, signal: AbortSignal.timeout(timeoutMs) };
868
- if (method !== 'GET' && method !== 'HEAD') init.body = JSON.stringify(body || {}); // GET (e.g. /v1/models) sends no body
869
- const res = await fetch(endpoint, init);
870
-
871
- const headers = Object.fromEntries(res.headers.entries());
872
- const contentType = (headers['content-type'] || '').toLowerCase();
873
- const isStream = contentType.includes('text/event-stream');
874
-
875
- return {
876
- status: res.status,
877
- headers,
878
- stream: isStream ? res.body : null,
879
- json: isStream ? null : () => res.json(),
880
- text: () => res.text(),
881
- };
882
- }
883
-
884
- // OpenAI Responses-compatible passthrough for Codex custom providers. The
885
- // proxy token is consumed by ProxyHttpServer and must never be forwarded as
886
- // upstream auth; the daemon supplies the real upstream key from env.
887
- async _proxyOpenAIResponses(reqPath, body, opts = {}) {
888
- const baseUrl = resolveOpenAIBaseUrl(opts.baseUrl || this._openaiBaseUrl || DEFAULT_OPENAI_BASE_URL, {
889
- trustedOverride: !!opts.baseUrl || this._openaiBaseUrlTrusted,
890
- });
891
- const inbound = opts.inboundHeaders || {};
892
- const timeoutMs = opts.timeoutMs || 60_000;
893
-
894
- const fwd = { 'content-type': 'application/json' };
895
- for (const [k, v] of Object.entries(inbound)) {
896
- if (v === undefined || v === null) continue;
897
- const lk = k.toLowerCase();
898
- if (
899
- lk === 'openai-organization'
900
- || lk === 'openai-project'
901
- || lk === 'openai-beta'
902
- || lk.startsWith('x-stainless-')
903
- ) {
904
- fwd[lk] = Array.isArray(v) ? v.join(', ') : String(v);
905
- }
906
- }
907
-
908
- const upstreamKey = process.env.EVOMAP_OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
909
- if (!upstreamKey) {
910
- const err = new Error('openai api key required');
911
- err.statusCode = 401;
912
- throw err;
913
- }
914
- if (upstreamKey) {
915
- fwd.authorization = `Bearer ${upstreamKey}`;
916
- }
917
-
918
- const endpoint = `${baseUrl}${reqPath}`;
919
- const abortController = new AbortController();
920
- const timeoutErr = new Error('openai upstream timed out');
921
- timeoutErr.name = 'TimeoutError';
922
- const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
923
- abortTimer.unref?.();
924
- const method = (opts.method || 'POST').toUpperCase();
925
- const init = { method, headers: fwd, signal: abortController.signal };
926
- if (method !== 'GET' && method !== 'HEAD') init.body = JSON.stringify(body || {}); // GET (e.g. /models) sends no body
927
- let res;
928
- try {
929
- res = await fetch(endpoint, init);
930
- } catch (err) {
931
- clearTimeout(abortTimer);
932
- throw makeOpenAIGatewayError(err);
933
- }
934
-
935
- const headers = Object.fromEntries(res.headers.entries());
936
- const contentType = (headers['content-type'] || '').toLowerCase();
937
- const isStream = contentType.includes('text/event-stream');
938
- if (isStream) clearTimeout(abortTimer);
939
-
940
- const readText = async () => {
941
- try {
942
- return await res.text();
943
- } catch (err) {
944
- throw makeOpenAIGatewayError(err);
945
- } finally {
946
- clearTimeout(abortTimer);
947
- }
948
- };
949
-
950
- return {
951
- status: res.status,
952
- headers,
953
- stream: isStream ? res.body : null,
954
- json: isStream ? null : async () => JSON.parse(await readText()),
955
- text: isStream ? null : readText,
956
- };
957
- }
958
-
959
- // Gemini upstream (Google Generative Language API). Native passthrough — the model + action live in the path
960
- // (`/v1beta/models/<model>:generateContent` | `:streamGenerateContent`), not the body, so we forward reqPath
961
- // (incl. query like ?alt=sse) verbatim. Auth is the `x-goog-api-key` header (proxy-mediated). No translation:
962
- // a Gemini-shaped request goes to a Gemini upstream, same return contract as the other providers.
963
- async _proxyGemini(reqPath, body, opts = {}) {
964
- const baseUrl = (opts.baseUrl || this._geminiBaseUrl || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, '');
965
- const inbound = opts.inboundHeaders || {};
966
- const timeoutMs = opts.timeoutMs || 60_000;
967
-
968
- const fwd = { 'content-type': 'application/json' };
969
- for (const [k, v] of Object.entries(inbound)) {
970
- if (v === undefined || v === null) continue;
971
- const lk = k.toLowerCase();
972
- // Forward Gemini metadata headers; the api key is injected below (never trust the inbound one).
973
- if (lk === 'x-goog-user-project' || lk === 'x-goog-api-client' || lk.startsWith('x-goog-request-')) {
974
- fwd[lk] = Array.isArray(v) ? v.join(', ') : String(v);
975
- }
976
- }
977
-
978
- const upstreamKey = process.env.EVOMAP_GEMINI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
979
- if (!upstreamKey) {
980
- const err = new Error('gemini api key required');
981
- err.statusCode = 401;
982
- throw err;
983
- }
984
- fwd['x-goog-api-key'] = upstreamKey;
985
-
986
- const endpoint = `${baseUrl}${reqPath}`;
987
- const abortController = new AbortController();
988
- const timeoutErr = new Error('gemini upstream timed out');
989
- timeoutErr.name = 'TimeoutError';
990
- const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
991
- abortTimer.unref?.();
992
- let res;
993
- try {
994
- res = await fetch(endpoint, {
995
- method: 'POST',
996
- headers: fwd,
997
- body: JSON.stringify(body || {}),
998
- signal: abortController.signal,
999
- });
1000
- } catch (err) {
1001
- clearTimeout(abortTimer);
1002
- throw makeGeminiGatewayError(err);
1003
- }
1004
-
1005
- const headers = Object.fromEntries(res.headers.entries());
1006
- const contentType = (headers['content-type'] || '').toLowerCase();
1007
- // `:streamGenerateContent` IS a stream regardless of content-type: with ?alt=sse it is text/event-stream,
1008
- // but the DEFAULT (no alt=sse) is a chunked JSON-array stream served as application/json. Detecting only by
1009
- // content-type would buffer + JSON.parse that array stream and hand the client a broken {error:...} wrapper
1010
- // instead of a live stream. Forward the body as a stream whenever the action is streamGenerateContent.
1011
- const isStream = contentType.includes('text/event-stream') || /:streamGenerateContent(\b|\?|$)/.test(reqPath);
1012
- if (isStream) clearTimeout(abortTimer);
1013
-
1014
- const readText = async () => {
1015
- try {
1016
- return await res.text();
1017
- } catch (err) {
1018
- throw makeGeminiGatewayError(err);
1019
- } finally {
1020
- clearTimeout(abortTimer);
1021
- }
1022
- };
1023
-
1024
- return {
1025
- status: res.status,
1026
- headers,
1027
- stream: isStream ? res.body : null,
1028
- json: isStream ? null : async () => JSON.parse(await readText()),
1029
- text: isStream ? null : readText,
1030
- };
1031
- }
1032
-
1033
- // Ollama native passthrough (local model server). Native paths /api/chat | /api/generate, body carries the
1034
- // model + `stream` flag. Ollama is typically local with no auth; forward verbatim to EVOMAP_OLLAMA_BASE_URL
1035
- // (default 127.0.0.1:11434). Optional bearer for a remote/protected Ollama via EVOMAP_OLLAMA_API_KEY. Streaming
1036
- // is newline-delimited JSON (NDJSON), not SSE — content-type application/json + chunked. No translation.
1037
- async _proxyOllama(reqPath, body, opts = {}) {
1038
- const baseUrl = (opts.baseUrl || this._ollamaBaseUrl || DEFAULT_OLLAMA_BASE_URL).replace(/\/+$/, '');
1039
- const timeoutMs = opts.timeoutMs || 60_000;
1040
- const fwd = { 'content-type': 'application/json' };
1041
- const upstreamKey = process.env.EVOMAP_OLLAMA_API_KEY || '';
1042
- if (upstreamKey) fwd.authorization = `Bearer ${upstreamKey}`;
1043
-
1044
- const endpoint = `${baseUrl}${reqPath}`;
1045
- const abortController = new AbortController();
1046
- const timeoutErr = new Error('ollama upstream timed out');
1047
- timeoutErr.name = 'TimeoutError';
1048
- const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
1049
- abortTimer.unref?.();
1050
- let res;
1051
- try {
1052
- res = await fetch(endpoint, { method: 'POST', headers: fwd, body: JSON.stringify(body || {}), signal: abortController.signal });
1053
- } catch (err) {
1054
- clearTimeout(abortTimer);
1055
- throw makeOllamaGatewayError(err);
1056
- }
1057
-
1058
- const headers = Object.fromEntries(res.headers.entries());
1059
- // Ollama streams when the request body has stream:true (default true). The reply is chunked NDJSON
1060
- // (application/json), not SSE, so detect by the request flag, not content-type — buffering a stream would
1061
- // break the client. Non-stream (stream:false) returns a single JSON object.
1062
- const isStream = !(body && body.stream === false);
1063
- if (isStream) clearTimeout(abortTimer);
1064
-
1065
- const readText = async () => {
1066
- try { return await res.text(); } catch (err) { throw makeOllamaGatewayError(err); } finally { clearTimeout(abortTimer); }
1067
- };
1068
-
1069
- return {
1070
- status: res.status,
1071
- headers,
1072
- stream: isStream ? res.body : null,
1073
- json: isStream ? null : async () => JSON.parse(await readText()),
1074
- text: isStream ? null : readText,
1075
- };
1076
- }
1077
-
1078
- // Vertex AI Gemini passthrough. Same Gemini body as the AI Studio route, but a Vertex path
1079
- // (/v1/projects/<p>/locations/<l>/publishers/google/models/<model>:generateContent), a region-specific base
1080
- // (<location>-aiplatform.googleapis.com, computed by the route and passed via opts.baseUrl), and OAuth Bearer
1081
- // auth. The access token comes from EVOMAP_VERTEX_ACCESS_TOKEN (provisioned/refreshed by the daemon via
1082
- // `gcloud auth print-access-token` or a token sidecar); SA-key auto-minting is a follow-up. No translation.
1083
- async _proxyVertex(reqPath, body, opts = {}) {
1084
- const baseUrl = String(opts.baseUrl || '').replace(/\/+$/, '');
1085
- if (!baseUrl) { const e = new Error('vertex base url required'); e.statusCode = 500; throw e; }
1086
- const token = process.env.EVOMAP_VERTEX_ACCESS_TOKEN || '';
1087
- if (!token) { const e = new Error('vertex access token required'); e.statusCode = 401; throw e; }
1088
- const timeoutMs = opts.timeoutMs || 60_000;
1089
- const fwd = { 'content-type': 'application/json', authorization: `Bearer ${token}` };
1090
-
1091
- const endpoint = `${baseUrl}${reqPath}`;
1092
- const abortController = new AbortController();
1093
- const timeoutErr = new Error('vertex upstream timed out');
1094
- timeoutErr.name = 'TimeoutError';
1095
- const abortTimer = setTimeout(() => abortController.abort(timeoutErr), timeoutMs);
1096
- abortTimer.unref?.();
1097
- let res;
1098
- try {
1099
- res = await fetch(endpoint, { method: 'POST', headers: fwd, body: JSON.stringify(body || {}), signal: abortController.signal });
1100
- } catch (err) {
1101
- clearTimeout(abortTimer);
1102
- throw makeVertexGatewayError(err);
1103
- }
1104
-
1105
- const headers = Object.fromEntries(res.headers.entries());
1106
- const contentType = (headers['content-type'] || '').toLowerCase();
1107
- const isStream = contentType.includes('text/event-stream') || /:streamGenerateContent(\b|\?|$)/.test(reqPath);
1108
- if (isStream) clearTimeout(abortTimer);
1109
-
1110
- const readText = async () => {
1111
- try { return await res.text(); } catch (err) { throw makeVertexGatewayError(err); } finally { clearTimeout(abortTimer); }
1112
- };
1113
-
1114
- return {
1115
- status: res.status,
1116
- headers,
1117
- stream: isStream ? res.body : null,
1118
- json: isStream ? null : async () => JSON.parse(await readText()),
1119
- text: isStream ? null : readText,
1120
- };
1121
- }
1122
-
1123
- // Bedrock upstream mode: same return contract as _proxyAnthropic so
1124
- // messages_route.js and ProxyHttpServer._streamResponse don't change.
1125
- // Body transformation: model -> URL path; inject anthropic_version;
1126
- // strip top-level model so Bedrock InvokeModel doesn't 400. SDK owns
1127
- // SigV4 signing (creds via AWS_* env or opts.bedrockCredentials for
1128
- // tests) and AWS event-stream binary decoding; we only re-emit each
1129
- // chunk as standard SSE so clients remain Anthropic-compatible.
1130
- async _proxyBedrock(reqPath, body, opts = {}) {
1131
- if (!this._bedrockSdk) {
1132
- this._bedrockSdk = require('@aws-sdk/client-bedrock-runtime');
1133
- }
1134
- const {
1135
- BedrockRuntimeClient,
1136
- InvokeModelCommand,
1137
- InvokeModelWithResponseStreamCommand,
1138
- } = this._bedrockSdk;
1139
-
1140
- // Defense-in-depth: when router is disabled (EVOMAP_ROUTER_ENABLED!=1)
1141
- // the router handler skips the body-rewrite step, so a short inbound ID
1142
- // would otherwise reach Bedrock InvokeModel and trigger ValidationException.
1143
- // Re-canonicalize here; idempotent for already-canonical IDs from the
1144
- // router-enabled path.
1145
- const rawModel = body && typeof body.model === 'string' ? body.model : null;
1146
- const modelId = rawModel ? canonicalizeForBedrock(rawModel) : null;
1147
- if (!modelId) {
1148
- const errBody = JSON.stringify({
1149
- type: 'error',
1150
- error: { type: 'invalid_request_error', message: 'body.model required for Bedrock upstream' },
1151
- });
1152
- return {
1153
- status: 400,
1154
- headers: { 'content-type': 'application/json' },
1155
- stream: null,
1156
- json: () => JSON.parse(errBody),
1157
- text: () => errBody,
1158
- };
1159
- }
1160
-
1161
- const upstreamBody = { ...body };
1162
- delete upstreamBody.model;
1163
- if (!upstreamBody.anthropic_version) {
1164
- upstreamBody.anthropic_version = 'bedrock-2023-05-31';
1165
- }
1166
- const wantsStream = upstreamBody.stream === true;
1167
- // Bedrock infers stream-vs-not from the command, not the body field.
1168
- delete upstreamBody.stream;
1169
-
1170
- // Claude Code v2.1.150+ sends `thinking: { type: 'adaptive' }` plus
1171
- // `output_config.effort` for Opus 4.7+. Keep that shape for those models:
1172
- // folding it to `enabled` makes current 4.7+ endpoints reject compaction
1173
- // with: "thinking.type.enabled is not supported for this model".
1174
- //
1175
- // Older Bedrock-deployed 4.5/4.1 generation models only accept
1176
- // 'enabled' | 'disabled'. Fold 'adaptive' for those older models:
1177
- //
1178
- // Two hard constraints collide:
1179
- // - Anthropic: budget_tokens >= 1024 when thinking is enabled
1180
- // - Bedrock: budget_tokens < max_tokens (strictly)
1181
- //
1182
- // For max_tokens <= 1024 there's no valid budget at all (1024 floor
1183
- // would fail Bedrock's strict-less-than check), so we have to drop
1184
- // thinking entirely on those calls — fold to 'disabled'. For larger
1185
- // max_tokens we default to max_tokens/2 (the model picks budget in
1186
- // adaptive mode, but Bedrock 'enabled' requires the field).
1187
- const modelSupportsAdaptiveThinking = supportsAdaptiveThinking(modelId);
1188
- if (
1189
- !modelSupportsAdaptiveThinking
1190
- && upstreamBody.thinking
1191
- && upstreamBody.thinking.type === 'adaptive'
1192
- ) {
1193
- const maxTokens = typeof upstreamBody.max_tokens === 'number' ? upstreamBody.max_tokens : 8192;
1194
- const haveBudget = typeof upstreamBody.thinking.budget_tokens === 'number';
1195
- if (!haveBudget && maxTokens <= 1024) {
1196
- upstreamBody.thinking = { type: 'disabled' };
1197
- } else {
1198
- upstreamBody.thinking = {
1199
- ...upstreamBody.thinking,
1200
- type: 'enabled',
1201
- budget_tokens: haveBudget ? upstreamBody.thinking.budget_tokens : Math.max(1024, Math.floor(maxTokens / 2)),
1202
- };
1203
- }
1204
- }
1205
-
1206
- // Claude Code v2.1.150+ adds top-level fields. Keep output_config for
1207
- // 4.7 adaptive thinking, where it controls effort; older Bedrock schemas
1208
- // reject it as an extra input.
1209
- //
1210
- // - output_config: { effort } (when effortLevel is set)
1211
- // - context_management: { ... } (auto context window management)
1212
- // Bedrock's strict schema means any unknown top-level field 400s the
1213
- // whole call, so strip the known CC additions before forwarding. New CC
1214
- // fields will surface as 400s and need to be added here.
1215
- for (const k of ['context_management']) {
1216
- if (k in upstreamBody) delete upstreamBody[k];
1217
- }
1218
- if (!modelSupportsAdaptiveThinking && 'output_config' in upstreamBody) {
1219
- delete upstreamBody.output_config;
1220
- }
1221
-
1222
- // Cache the BedrockRuntimeClient across requests so its connection
1223
- // pool, DNS cache, and credential-chain resolution amortize. Reusing
1224
- // a single client matches what _proxyAnthropic does with the global
1225
- // fetch + Agent. Cache key includes the SDK module identity so test
1226
- // SDK injection (proxy._bedrockSdk = mock) invalidates correctly.
1227
- const clientArgs = {
1228
- region: opts.bedrockRegion || process.env.AWS_REGION || 'us-east-1',
1229
- ...(opts.bedrockEndpoint || process.env.EVOMAP_BEDROCK_ENDPOINT
1230
- ? { endpoint: opts.bedrockEndpoint || process.env.EVOMAP_BEDROCK_ENDPOINT }
1231
- : {}),
1232
- ...(opts.bedrockCredentials ? { credentials: opts.bedrockCredentials } : {}),
1233
- };
1234
- const cacheKey = JSON.stringify(clientArgs);
1235
- if (
1236
- !this._bedrockClient
1237
- || this._bedrockClientKey !== cacheKey
1238
- || this._bedrockClientSdk !== this._bedrockSdk
1239
- ) {
1240
- this._bedrockClient = new BedrockRuntimeClient(clientArgs);
1241
- this._bedrockClientKey = cacheKey;
1242
- this._bedrockClientSdk = this._bedrockSdk;
1243
- }
1244
- const client = this._bedrockClient;
1245
-
1246
- // Match _proxyAnthropic's per-request timeout boundary so a hung
1247
- // upstream can't pin a Bedrock connection forever. AWS SDK v3
1248
- // commands accept abortSignal in the second arg.
1249
- const timeoutMs = opts.timeoutMs || 60_000;
1250
- const abortController = new AbortController();
1251
- const abortTimer = setTimeout(() => abortController.abort(), timeoutMs);
1252
-
1253
- try {
1254
- if (wantsStream) {
1255
- const out = await client.send(new InvokeModelWithResponseStreamCommand({
1256
- modelId,
1257
- contentType: 'application/json',
1258
- accept: 'application/json',
1259
- body: JSON.stringify(upstreamBody),
1260
- }), { abortSignal: abortController.signal });
1261
- // The timeout that bounds the initial send must not apply to the
1262
- // streaming body — chunks arrive over many seconds. Clear it now;
1263
- // the readable-stream's cancel() handler is what closes the
1264
- // upstream when the client disconnects mid-stream.
1265
- clearTimeout(abortTimer);
1266
- const stream = new ReadableStream({
1267
- async start(controller) {
1268
- const enc = new TextEncoder();
1269
- try {
1270
- for await (const event of out.body) {
1271
- if (event.chunk?.bytes) {
1272
- const json = Buffer.from(event.chunk.bytes).toString('utf8');
1273
- controller.enqueue(enc.encode(`data: ${json}\n\n`));
1274
- continue;
1275
- }
1276
- // Bedrock InvokeModelWithResponseStream may emit any of these
1277
- // exception envelopes mid-stream; missing one silently drops
1278
- // it and closes the stream without an error frame, so the
1279
- // client sees a truncated-but-clean response.
1280
- const ex = event.internalServerException
1281
- || event.modelStreamErrorException
1282
- || event.throttlingException
1283
- || event.validationException
1284
- || event.modelTimeoutException
1285
- || event.serviceUnavailableException;
1286
- if (ex) {
1287
- const errFrame = JSON.stringify({
1288
- type: 'error',
1289
- error: { type: ex.name || 'upstream_error', message: ex.message || String(ex) },
1290
- });
1291
- controller.enqueue(enc.encode(`event: error\ndata: ${errFrame}\n\n`));
1292
- }
1293
- }
1294
- controller.close();
1295
- } catch (err) {
1296
- controller.error(err);
1297
- }
1298
- },
1299
- // ProxyHttpServer._streamResponse calls reader.cancel() when the
1300
- // downstream HTTP client disconnects. Without this, the AWS
1301
- // event-stream AsyncIterable keeps pulling frames into a
1302
- // discarded ReadableStream, leaking the underlying HTTP/2
1303
- // stream + socket out of the SDK's pool.
1304
- cancel() {
1305
- try {
1306
- if (typeof out.body?.return === 'function') {
1307
- out.body.return();
1308
- }
1309
- } catch { /* AsyncIterable already closed */ }
1310
- },
1311
- });
1312
- return {
1313
- status: 200,
1314
- headers: { 'content-type': 'text/event-stream' },
1315
- stream,
1316
- json: null,
1317
- text: null,
1318
- traceRequestBody: upstreamBody,
1319
- };
1320
- }
1321
-
1322
- const out = await client.send(new InvokeModelCommand({
1323
- modelId,
1324
- contentType: 'application/json',
1325
- accept: 'application/json',
1326
- body: JSON.stringify(upstreamBody),
1327
- }), { abortSignal: abortController.signal });
1328
- clearTimeout(abortTimer);
1329
- const text = Buffer.from(out.body).toString('utf8');
1330
- return {
1331
- status: 200,
1332
- headers: { 'content-type': 'application/json' },
1333
- stream: null,
1334
- json: () => JSON.parse(text),
1335
- text: () => text,
1336
- traceRequestBody: upstreamBody,
1337
- };
1338
- } catch (err) {
1339
- clearTimeout(abortTimer);
1340
- const status = err.$metadata?.httpStatusCode || 500;
1341
- const errBody = JSON.stringify({
1342
- type: 'error',
1343
- error: { type: err.name || 'upstream_error', message: err.message || String(err) },
1344
- });
1345
- return {
1346
- status,
1347
- headers: { 'content-type': 'application/json' },
1348
- stream: null,
1349
- json: () => JSON.parse(errBody),
1350
- text: () => errBody,
1351
- traceRequestBody: upstreamBody,
1352
- };
1353
- }
1354
- }
1355
-
1356
- async _getHubMailboxStatus() {
1357
- if (!this.hubUrl) return { error: 'Hub not configured' };
1358
- const nodeId = this.lifecycle.nodeId;
1359
- if (!nodeId) return { error: 'No node_id yet' };
1360
- const endpoint = `${this.hubUrl}/a2a/mailbox/status?node_id=${encodeURIComponent(nodeId)}`;
1361
- try {
1362
- const res = await hubFetch(endpoint, {
1363
- method: 'GET',
1364
- headers: this.lifecycle._buildHeaders(),
1365
- signal: AbortSignal.timeout(10_000),
1366
- });
1367
- if (!res.ok) {
1368
- // Drain body so undici can recycle the socket back to the pool.
1369
- // Without this, repeated non-ok responses leak pool slots and
1370
- // eventually starve the dispatcher.
1371
- try { res.body?.cancel?.().catch(() => {}); } catch {}
1372
- return { error: `Hub ${res.status}` };
1373
- }
1374
- return res.json();
1375
- } catch (err) {
1376
- return { error: err.message };
1377
- }
1378
- }
1379
- }
1380
-
1381
- async function startProxy(opts = {}) {
1382
- const proxy = new EvoMapProxy(opts);
1383
- const info = await proxy.start();
1384
- return { proxy, ...info };
1385
- }
1386
-
1387
- module.exports = {
1388
- EvoMapProxy,
1389
- startProxy,
1390
- buildAssetSearchQuery,
1391
- buildSemanticSearchQuery,
1392
- planAssetSearch,
1393
- parseRetryAfterMs,
1394
- resolveOpenAIBaseUrl,
1395
- };