@amodalai/runtime 0.1.15 → 0.1.17

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 (121) hide show
  1. package/dist/src/agent/agent-runner.js +13 -1
  2. package/dist/src/agent/agent-runner.js.map +1 -1
  3. package/dist/src/agent/config-watcher.d.ts +2 -2
  4. package/dist/src/agent/config-watcher.js +28 -15
  5. package/dist/src/agent/config-watcher.js.map +1 -1
  6. package/dist/src/agent/config-watcher.test.js +13 -4
  7. package/dist/src/agent/config-watcher.test.js.map +1 -1
  8. package/dist/src/agent/eval-store.d.ts +14 -0
  9. package/dist/src/agent/eval-store.js +44 -0
  10. package/dist/src/agent/eval-store.js.map +1 -1
  11. package/dist/src/agent/feedback-store.d.ts +39 -0
  12. package/dist/src/agent/feedback-store.js +98 -0
  13. package/dist/src/agent/feedback-store.js.map +1 -0
  14. package/dist/src/agent/local-server.d.ts +1 -1
  15. package/dist/src/agent/local-server.js +48 -28
  16. package/dist/src/agent/local-server.js.map +1 -1
  17. package/dist/src/agent/local-server.test.js +2 -1
  18. package/dist/src/agent/local-server.test.js.map +1 -1
  19. package/dist/src/agent/proactive/proactive-runner.d.ts +3 -3
  20. package/dist/src/agent/proactive/proactive-runner.js +3 -3
  21. package/dist/src/agent/proactive/proactive-runner.js.map +1 -1
  22. package/dist/src/agent/proactive/proactive-runner.test.js +2 -2
  23. package/dist/src/agent/proactive/proactive-runner.test.js.map +1 -1
  24. package/dist/src/agent/routes/admin-chat.d.ts +3 -2
  25. package/dist/src/agent/routes/admin-chat.js +3 -3
  26. package/dist/src/agent/routes/admin-chat.js.map +1 -1
  27. package/dist/src/agent/routes/chat.d.ts +6 -6
  28. package/dist/src/agent/routes/chat.js +3 -3
  29. package/dist/src/agent/routes/chat.js.map +1 -1
  30. package/dist/src/agent/routes/chat.test.js +7 -6
  31. package/dist/src/agent/routes/chat.test.js.map +1 -1
  32. package/dist/src/agent/routes/evals.d.ts +2 -2
  33. package/dist/src/agent/routes/evals.js +143 -68
  34. package/dist/src/agent/routes/evals.js.map +1 -1
  35. package/dist/src/agent/routes/feedback.d.ts +11 -0
  36. package/dist/src/agent/routes/feedback.js +72 -0
  37. package/dist/src/agent/routes/feedback.js.map +1 -0
  38. package/dist/src/agent/routes/files.js +118 -12
  39. package/dist/src/agent/routes/files.js.map +1 -1
  40. package/dist/src/agent/routes/inspect.d.ts +2 -2
  41. package/dist/src/agent/routes/inspect.js +44 -22
  42. package/dist/src/agent/routes/inspect.js.map +1 -1
  43. package/dist/src/agent/routes/inspect.test.js +11 -16
  44. package/dist/src/agent/routes/inspect.test.js.map +1 -1
  45. package/dist/src/agent/routes/task.d.ts +2 -2
  46. package/dist/src/agent/routes/task.js +4 -4
  47. package/dist/src/agent/routes/task.js.map +1 -1
  48. package/dist/src/agent/routes/task.test.js.map +1 -1
  49. package/dist/src/agent/session-store.d.ts +2 -2
  50. package/dist/src/agent/session-store.js +9 -6
  51. package/dist/src/agent/session-store.js.map +1 -1
  52. package/dist/src/agent/snapshot-server.js +10 -2
  53. package/dist/src/agent/snapshot-server.js.map +1 -1
  54. package/dist/src/cron/heartbeat-runner.d.ts +3 -6
  55. package/dist/src/cron/heartbeat-runner.js +1 -10
  56. package/dist/src/cron/heartbeat-runner.js.map +1 -1
  57. package/dist/src/index.d.ts +5 -3
  58. package/dist/src/index.js +4 -9
  59. package/dist/src/index.js.map +1 -1
  60. package/dist/src/middleware/auth.d.ts +3 -19
  61. package/dist/src/middleware/auth.js +0 -118
  62. package/dist/src/middleware/auth.js.map +1 -1
  63. package/dist/src/routes/ai-stream.d.ts +8 -7
  64. package/dist/src/routes/ai-stream.js +3 -16
  65. package/dist/src/routes/ai-stream.js.map +1 -1
  66. package/dist/src/routes/chat-stream.d.ts +4 -3
  67. package/dist/src/routes/chat-stream.js +5 -17
  68. package/dist/src/routes/chat-stream.js.map +1 -1
  69. package/dist/src/routes/chat.d.ts +4 -2
  70. package/dist/src/routes/chat.js +2 -14
  71. package/dist/src/routes/chat.js.map +1 -1
  72. package/dist/src/routes/chat.test.js +2 -2
  73. package/dist/src/routes/chat.test.js.map +1 -1
  74. package/dist/src/server.d.ts +16 -3
  75. package/dist/src/server.js +24 -27
  76. package/dist/src/server.js.map +1 -1
  77. package/dist/src/server.test.js +37 -6
  78. package/dist/src/server.test.js.map +1 -1
  79. package/dist/src/session/admin-file-tools.d.ts +136 -0
  80. package/dist/src/session/admin-file-tools.js +240 -0
  81. package/dist/src/session/admin-file-tools.js.map +1 -0
  82. package/dist/src/session/custom-tool-adapter.d.ts +74 -0
  83. package/dist/src/session/custom-tool-adapter.js +149 -0
  84. package/dist/src/session/custom-tool-adapter.js.map +1 -0
  85. package/dist/src/session/session-manager.d.ts +101 -3
  86. package/dist/src/session/session-manager.js +467 -31
  87. package/dist/src/session/session-manager.js.map +1 -1
  88. package/dist/src/session/session-manager.test.js +93 -58
  89. package/dist/src/session/session-manager.test.js.map +1 -1
  90. package/dist/src/session/session-runner.d.ts +29 -13
  91. package/dist/src/session/session-runner.js +40 -91
  92. package/dist/src/session/session-runner.js.map +1 -1
  93. package/dist/src/session/session-runner.test.js +70 -80
  94. package/dist/src/session/session-runner.test.js.map +1 -1
  95. package/dist/src/types.d.ts +1 -0
  96. package/dist/tsconfig.tsbuildinfo +1 -1
  97. package/package.json +2 -2
  98. package/dist/src/agent/agent-runner.test.d.ts +0 -6
  99. package/dist/src/agent/agent-runner.test.js +0 -552
  100. package/dist/src/agent/agent-runner.test.js.map +0 -1
  101. package/dist/src/agent/session-manager.d.ts +0 -88
  102. package/dist/src/agent/session-manager.js +0 -341
  103. package/dist/src/agent/session-manager.js.map +0 -1
  104. package/dist/src/agent/session-manager.test.d.ts +0 -6
  105. package/dist/src/agent/session-manager.test.js +0 -145
  106. package/dist/src/agent/session-manager.test.js.map +0 -1
  107. package/dist/src/audit/audit-client.d.ts +0 -46
  108. package/dist/src/audit/audit-client.js +0 -83
  109. package/dist/src/audit/audit-client.js.map +0 -1
  110. package/dist/src/middleware/auth.test.d.ts +0 -6
  111. package/dist/src/middleware/auth.test.js +0 -260
  112. package/dist/src/middleware/auth.test.js.map +0 -1
  113. package/dist/src/routes/sessions.d.ts +0 -14
  114. package/dist/src/routes/sessions.js +0 -82
  115. package/dist/src/routes/sessions.js.map +0 -1
  116. package/dist/src/utils/jwt-verify.d.ts +0 -19
  117. package/dist/src/utils/jwt-verify.js +0 -32
  118. package/dist/src/utils/jwt-verify.js.map +0 -1
  119. package/dist/src/utils/jwt-verify.test.d.ts +0 -6
  120. package/dist/src/utils/jwt-verify.test.js +0 -150
  121. package/dist/src/utils/jwt-verify.test.js.map +0 -1
@@ -4,23 +4,59 @@
4
4
  * SPDX-License-Identifier: MIT
5
5
  */
6
6
  import { randomUUID } from 'node:crypto';
7
- import { AmodalConfig, Scheduler, ROOT_SCHEDULER_ID, ApprovalMode, PolicyDecision, AgentSDK, buildDefaultPrompt, } from '@amodalai/core';
7
+ import { AmodalConfig, Scheduler, ROOT_SCHEDULER_ID, ApprovalMode, PolicyDecision, AgentSDK, buildDefaultPrompt, resolveScopeLabels, generateFieldGuidance, generateAlternativeLookupGuidance, PlanModeManager, McpManager, ensureAdminAgent, loadAdminAgent, } from '@amodalai/core';
8
8
  import { convertSessionMessagesToHistory } from './history-converter.js';
9
+ /**
10
+ * Resolve env: references in a string record.
11
+ * "env:VAR_NAME" → process.env.VAR_NAME value
12
+ */
13
+ function resolveEnvRefs(record) {
14
+ const resolved = {};
15
+ for (const [key, value] of Object.entries(record)) {
16
+ if (value.startsWith('env:')) {
17
+ const envVar = value.slice(4);
18
+ resolved[key] = process.env[envVar] ?? '';
19
+ }
20
+ else {
21
+ resolved[key] = value;
22
+ }
23
+ }
24
+ return resolved;
25
+ }
9
26
  const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
10
27
  const DEFAULT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
11
28
  const ASK_USER_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
29
+ /**
30
+ * Manages per-request sessions: creates Config + GeminiClient + Scheduler
31
+ * instances, tracks them by ID, and cleans up expired sessions.
32
+ */
12
33
  export class SessionManager {
13
34
  sessions = new Map();
14
35
  baseParams;
15
36
  ttlMs;
16
37
  platformApiUrl;
38
+ repo;
39
+ toolExecutor;
40
+ shellExecutor;
41
+ sharedStoreBackend;
42
+ sessionStore;
17
43
  cleanupTimer = null;
18
44
  /** Deduplicates concurrent hydration requests for the same conversation */
19
45
  pendingHydrations = new Map();
46
+ /** Shared MCP manager for all sessions (lazy-initialized, reused) */
47
+ sharedMcpManager;
48
+ /** Persistent MCP manager for inspect operations (lazy-initialized) */
49
+ inspectMcp;
50
+ inspectMcpInitialized = false;
20
51
  constructor(options) {
21
52
  this.baseParams = options.baseParams;
22
53
  this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
23
54
  this.platformApiUrl = options.platformApiUrl;
55
+ this.repo = options.repo;
56
+ this.toolExecutor = options.toolExecutor;
57
+ this.shellExecutor = options.shellExecutor;
58
+ this.sharedStoreBackend = options.storeBackend;
59
+ this.sessionStore = options.sessionStore;
24
60
  const cleanupIntervalMs = options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
25
61
  this.cleanupTimer = setInterval(() => void this.cleanup(), cleanupIntervalMs);
26
62
  // Don't keep the process alive just for cleanup
@@ -39,9 +75,8 @@ export class SessionManager {
39
75
  approvalMode: ApprovalMode.YOLO,
40
76
  interactive: false,
41
77
  noBrowser: true,
42
- // Disable all upstream Gemini CLI tools by default.
43
- // Only Amodal platform tools (request, present, etc.) are registered.
44
- coreTools: [],
78
+ // Build coreTools list based on repo config
79
+ coreTools: this.repo ? this.buildCoreToolsList(this.repo) : [],
45
80
  policyEngineConfig: {
46
81
  approvalMode: ApprovalMode.YOLO,
47
82
  defaultDecision: PolicyDecision.ALLOW,
@@ -55,6 +90,45 @@ export class SessionManager {
55
90
  if (role) {
56
91
  sessionParams.activeRole = role;
57
92
  }
93
+ // Inject repo config into session params when in local mode
94
+ if (this.repo) {
95
+ const { buildConnectionsMap } = await import('@amodalai/core');
96
+ const connectionsMap = buildConnectionsMap(this.repo.connections);
97
+ sessionParams.connections = connectionsMap;
98
+ sessionParams.appDocuments = this.repo.knowledge.map((k) => ({
99
+ id: k.name,
100
+ scope_type: 'application',
101
+ scope_id: 'local',
102
+ title: k.title ?? k.name,
103
+ category: 'system_docs',
104
+ body: k.body,
105
+ tags: [],
106
+ status: 'active',
107
+ created_by: 'local',
108
+ created_at: new Date().toISOString(),
109
+ updated_at: new Date().toISOString(),
110
+ }));
111
+ sessionParams.bundleSkills = this.repo.skills.map((s) => ({
112
+ name: s.name,
113
+ description: s.description ?? '',
114
+ body: s.body,
115
+ }));
116
+ sessionParams.basePrompt = this.repo.config.basePrompt;
117
+ sessionParams.agentName = this.repo.config.name;
118
+ sessionParams.agentContext = this.repo.config.userContext ?? this.repo.config.description;
119
+ // Model config from repo
120
+ const mainModel = this.repo.config.models?.main;
121
+ if (mainModel) {
122
+ sessionParams.modelConfig = {
123
+ provider: mainModel.provider ?? 'anthropic',
124
+ model: mainModel.model,
125
+ };
126
+ }
127
+ // Stores
128
+ if (this.repo.stores.length > 0) {
129
+ sessionParams.stores = this.repo.stores;
130
+ }
131
+ }
58
132
  let config;
59
133
  // Platform session: use AgentSDK to fetch KB docs, org details, secrets
60
134
  if (auth?.token && this.platformApiUrl) {
@@ -87,7 +161,82 @@ export class SessionManager {
87
161
  }
88
162
  }
89
163
  config = new AmodalConfig(sessionParams);
90
- await config.initialize();
164
+ // For non-platform sessions, skip the full upstream Config.initialize()
165
+ // which hangs trying to scan files, discover agents, and authenticate
166
+ // with Gemini. Instead, do a minimal init:
167
+ // 1. initializeAuth() — sets up MultiProviderContentGenerator for non-Google
168
+ // 2. registerTools() — registers amodal tools on the upstream registry
169
+ //
170
+ // We need to manually create the tool registry and message bus since
171
+ // the upstream Config only creates them in _initialize().
172
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
173
+ const upstreamRaw = config.getUpstreamConfig();
174
+ // Initialize storage (required by many upstream components)
175
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
176
+ const storage = (upstreamRaw['storage'] ?? upstreamRaw['_storage']);
177
+ if (storage?.initialize) {
178
+ await storage.initialize();
179
+ }
180
+ // Create agent registry and tool registry if not already created
181
+ if (!upstreamRaw['toolRegistry'] && !upstreamRaw['_toolRegistry']) {
182
+ // Agent registry must exist before tool registry (createToolRegistry references it).
183
+ // Use a minimal stub — we register Amodal subagents separately.
184
+ if (!upstreamRaw['agentRegistry']) {
185
+ upstreamRaw['agentRegistry'] = {
186
+ getAllDefinitions: () => [],
187
+ agents: new Map(),
188
+ allDefinitions: new Map(),
189
+ initialize: async () => { },
190
+ };
191
+ }
192
+ // Prompt registry stub (referenced by some tools)
193
+ if (!upstreamRaw['promptRegistry']) {
194
+ upstreamRaw['promptRegistry'] = { getPrompts: () => [] };
195
+ }
196
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
197
+ const upstreamConfig = config.getUpstreamConfig();
198
+ const registry = await upstreamConfig.createToolRegistry();
199
+ upstreamRaw['_toolRegistry'] = registry;
200
+ }
201
+ // Initialize auth (replaces content generator for non-Google providers)
202
+ await config.initializeAuth();
203
+ // Register amodal tools (request, present, knowledge, stores)
204
+ await config.registerTools();
205
+ // Register custom tools from repo tools/ directory
206
+ if (this.repo && this.repo.tools.length > 0) {
207
+ const { CustomToolAdapter } = await import('./custom-tool-adapter.js');
208
+ const { LocalToolExecutor } = await import('../agent/tool-executor-local.js');
209
+ const executor = this.toolExecutor ?? new LocalToolExecutor();
210
+ const registry = config.getUpstreamConfig().getToolRegistry();
211
+ // Session isn't created yet — we'll set it after session construction
212
+ // For now store the tools to register after session is built
213
+ for (const tool of this.repo.tools) {
214
+ if (tool.confirm === 'never')
215
+ continue; // hidden from LLM
216
+ // Create a placeholder session for the adapter — will be updated below
217
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- partial session for tool context
218
+ const placeholder = { config, toolExecutor: executor };
219
+ const adapter = new CustomToolAdapter(tool, placeholder, executor);
220
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- adapter matches upstream tool interface
221
+ registry.registerTool(adapter);
222
+ }
223
+ process.stderr.write(`[SESSION] Registered ${String(this.repo.tools.length)} custom tool(s)\n`);
224
+ }
225
+ // Set model on upstream config so GeminiClient.startChat() can resolve it
226
+ if (sessionParams.modelConfig?.model) {
227
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
228
+ const upstreamForModel = config.getUpstreamConfig();
229
+ if (!upstreamForModel.model && !upstreamForModel._model) {
230
+ upstreamForModel['model'] = sessionParams.modelConfig.model;
231
+ upstreamForModel['_model'] = sessionParams.modelConfig.model;
232
+ }
233
+ }
234
+ // Initialize the GeminiClient (creates the Chat instance)
235
+ // Must happen after tool registry and content generator are set up.
236
+ const gemClient = config.getGeminiClient();
237
+ if (!gemClient.isInitialized()) {
238
+ await gemClient.initialize();
239
+ }
91
240
  }
92
241
  // Inject app secrets as process env vars so shell_exec commands can
93
242
  // reference them (e.g. $API_BASE_URL in curl commands). The shell execution
@@ -97,11 +246,6 @@ export class SessionManager {
97
246
  const connections = config.getConnections();
98
247
  const connKeys = Object.keys(connections).filter((k) => k !== '_secrets');
99
248
  process.stderr.write(`[SESSION] connections: ${connKeys.join(', ') || '(none)'}\n`);
100
- if (sessionType === 'onboarding' && !connections['platform-api']) {
101
- process.stderr.write(`[SESSION] WARNING: onboarding session has no platform-api connection — ` +
102
- `the request tool cannot create resources. Check that ADMIN_APP_ID is set ` +
103
- `and the seed has run.\n`);
104
- }
105
249
  // App secrets are available to tools via session-scoped getSessionEnv()
106
250
  // (through ToolContext). They are NOT injected into process.env to prevent
107
251
  // cross-app secret leakage in multi-session runtimes.
@@ -139,9 +283,10 @@ export class SessionManager {
139
283
  // Initialize store backend if stores are configured.
140
284
  // Must happen before initializeAuth/tool registration so store tools
141
285
  // are available when registerAmodalTools runs.
286
+ // In local dev mode, use the shared store backend from options.
142
287
  const stores = config.getStores();
143
- let storeBackend;
144
- if (stores.length > 0) {
288
+ let storeBackend = this.sharedStoreBackend;
289
+ if (stores.length > 0 && !storeBackend) {
145
290
  try {
146
291
  const { PGLiteStoreBackend } = await import('../stores/pglite-store-backend.js');
147
292
  const backend = new PGLiteStoreBackend();
@@ -166,7 +311,36 @@ export class SessionManager {
166
311
  name: config.getAgentName() ?? 'Amodal Agent',
167
312
  description: config.getAgentDescription(),
168
313
  agentContext: config.getAgentContext(),
169
- connectionNames: Object.keys(config.getConnections()).filter((k) => k !== '_secrets'),
314
+ agentOverride: this.repo?.agents?.main,
315
+ connections: this.repo?.connections ? Array.from(this.repo.connections.values()).map((conn) => ({
316
+ name: conn.name,
317
+ endpoints: (conn.surface ?? [])
318
+ .filter((ep) => ep.included)
319
+ .map((ep) => ({ method: ep.method, path: ep.path, description: ep.description })),
320
+ entities: conn.entities,
321
+ rules: conn.rules,
322
+ })) : undefined,
323
+ skills: this.repo?.skills?.map((s) => ({
324
+ name: s.name,
325
+ description: s.description ?? '',
326
+ trigger: s.trigger,
327
+ body: s.body,
328
+ })),
329
+ knowledge: this.repo?.knowledge?.map((k) => ({
330
+ name: k.name,
331
+ title: k.title,
332
+ body: k.body,
333
+ })),
334
+ ...(this.repo?.connections ? (() => {
335
+ const { scopeLabels } = resolveScopeLabels(this.repo.connections, []);
336
+ const fieldGuidance = generateFieldGuidance(this.repo.connections, []);
337
+ const altLookup = generateAlternativeLookupGuidance(this.repo.connections);
338
+ return {
339
+ fieldGuidance: fieldGuidance || undefined,
340
+ scopeLabels: Object.keys(scopeLabels).length > 0 ? scopeLabels : undefined,
341
+ alternativeLookupGuidance: altLookup || undefined,
342
+ };
343
+ })() : {}),
170
344
  });
171
345
  try {
172
346
  geminiClient.getChat().setSystemInstruction(systemPrompt);
@@ -283,7 +457,79 @@ export class SessionManager {
283
457
  model: mc?.model ?? config.getModel(),
284
458
  provider: mc?.provider,
285
459
  storeBackend,
460
+ planModeManager: new PlanModeManager(),
461
+ toolExecutor: this.toolExecutor,
462
+ shellExecutor: this.shellExecutor,
463
+ appId: auth?.applicationId,
286
464
  };
465
+ // Share MCP connection across sessions — connect once, reuse for all
466
+ if (this.repo && !this.sharedMcpManager) {
467
+ await this.initSharedMcp(this.repo);
468
+ }
469
+ if (this.sharedMcpManager) {
470
+ session.mcpManager = this.sharedMcpManager;
471
+ // Register MCP tools on the upstream tool registry so the Gemini client can see them
472
+ try {
473
+ const upstream = config.getUpstreamConfig();
474
+ const toolRegistry = upstream.getToolRegistry();
475
+ const mcpTools = session.mcpManager.getDiscoveredTools();
476
+ for (const mcpTool of mcpTools) {
477
+ // Adapter matching upstream DeclarativeTool interface (build/silentBuild/schema/getSchema)
478
+ const mcpSession = session;
479
+ const adapter = {
480
+ name: mcpTool.name,
481
+ displayName: mcpTool.name,
482
+ description: mcpTool.description,
483
+ kind: 'declarative',
484
+ parameterSchema: mcpTool.parameters,
485
+ get isReadOnly() { return true; },
486
+ get toolAnnotations() { return undefined; },
487
+ get schema() { return this.getSchema(); },
488
+ getSchema() {
489
+ return {
490
+ name: mcpTool.name,
491
+ description: mcpTool.description,
492
+ parametersJsonSchema: mcpTool.parameters,
493
+ };
494
+ },
495
+ build(params) {
496
+ return {
497
+ name: mcpTool.name,
498
+ params,
499
+ execute: async () => adapter.validateBuildAndExecute(params),
500
+ };
501
+ },
502
+ silentBuild(params) {
503
+ return this.build(params);
504
+ },
505
+ async validateBuildAndExecute(params) {
506
+ try {
507
+ const result = await mcpSession.mcpManager.callTool(mcpTool.name, params);
508
+ const output = result.content
509
+ .map((c) => c.type === 'text' && c.text ? c.text : `[${c.type}]`)
510
+ .join('\n');
511
+ return {
512
+ llmContent: result.isError ? `Error: ${output}` : output,
513
+ returnDisplay: output.slice(0, 200),
514
+ ...(result.isError ? { error: { message: output, type: 'EXECUTION_FAILED' } } : {}),
515
+ };
516
+ }
517
+ catch (err) {
518
+ const msg = err instanceof Error ? err.message : String(err);
519
+ return { llmContent: `Error: ${msg}`, returnDisplay: msg, error: { message: msg, type: 'EXECUTION_FAILED' } };
520
+ }
521
+ },
522
+ };
523
+ toolRegistry.registerTool(adapter); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
524
+ }
525
+ await geminiClient.setTools();
526
+ process.stderr.write(`[MCP] Registered ${String(mcpTools.length)} MCP tools on tool registry\n`);
527
+ }
528
+ catch (err) {
529
+ const msg = err instanceof Error ? err.message : String(err);
530
+ process.stderr.write(`[MCP] Failed to register MCP tools: ${msg}\n`);
531
+ }
532
+ }
287
533
  this.sessions.set(sessionId, session);
288
534
  return session;
289
535
  }
@@ -329,31 +575,20 @@ export class SessionManager {
329
575
  }
330
576
  }
331
577
  async doHydrate(conversationId, role, auth, sessionType) {
332
- // Fetch stored conversation from platform-api
333
- const url = `${this.platformApiUrl}/api/applications/${auth.applicationId}/sessions/${conversationId}`;
578
+ // Fetch stored conversation via pluggable session store
579
+ if (!this.sessionStore || !auth?.applicationId || !auth.token) {
580
+ return null;
581
+ }
334
582
  let record;
335
583
  try {
336
- const controller = new AbortController();
337
- const timer = setTimeout(() => controller.abort(), 10_000);
338
- const response = await fetch(url, {
339
- signal: controller.signal,
340
- headers: {
341
- Authorization: `Bearer ${auth.token}`,
342
- Accept: 'application/json',
343
- },
344
- });
345
- clearTimeout(timer);
346
- if (!response.ok) {
347
- process.stderr.write(`[HYDRATE] Failed to fetch conversation ${conversationId}: HTTP ${response.status}\n`);
348
- return null;
349
- }
350
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- API response shape
351
- record = (await response.json());
584
+ record = await this.sessionStore.getSession(auth.applicationId, conversationId, auth.token);
352
585
  }
353
586
  catch (err) {
354
587
  process.stderr.write(`[HYDRATE] Error fetching conversation ${conversationId}: ${err instanceof Error ? err.message : String(err)}\n`);
355
588
  return null;
356
589
  }
590
+ if (!record)
591
+ return null;
357
592
  // No messages → nothing to hydrate
358
593
  if (!record.messages || record.messages.length === 0)
359
594
  return null;
@@ -395,6 +630,195 @@ export class SessionManager {
395
630
  }
396
631
  await session.config.shutdownAudit();
397
632
  }
633
+ // ---------------------------------------------------------------------------
634
+ // Local dev features
635
+ // ---------------------------------------------------------------------------
636
+ /**
637
+ * Get the repo (local dev mode).
638
+ */
639
+ getRepo() {
640
+ return this.repo;
641
+ }
642
+ /**
643
+ * Update the repo for new sessions (hot reload).
644
+ * Existing sessions keep their old config.
645
+ */
646
+ updateRepo(repo) {
647
+ this.repo = repo;
648
+ // Reset inspect MCP manager so it picks up new connections
649
+ this.inspectMcpInitialized = false;
650
+ this.inspectMcp = undefined;
651
+ }
652
+ /**
653
+ * Re-register a session under a different ID (e.g., restoring original ID on session restore).
654
+ */
655
+ reregister(session, newId) {
656
+ this.sessions.delete(session.id);
657
+ session.id = newId;
658
+ this.sessions.set(newId, session);
659
+ }
660
+ /**
661
+ * Create an admin session for the config chat.
662
+ * Uses admin agent skills/knowledge but the current repo's connections/stores.
663
+ *
664
+ * Temporarily swaps repo fields so create() builds the prompt with admin
665
+ * content, then restores the original repo. This mirrors the old approach
666
+ * of building an adminRepo overlay.
667
+ */
668
+ async createAdminSession(getPort) {
669
+ if (!this.repo) {
670
+ throw new Error('Admin sessions require a repo');
671
+ }
672
+ if (this.repo.source !== 'local') {
673
+ throw new Error('Admin sessions are only available for local repos');
674
+ }
675
+ const agentDir = await ensureAdminAgent(this.repo.origin);
676
+ const adminContent = await loadAdminAgent(agentDir);
677
+ // Save original repo fields
678
+ const origSkills = this.repo.skills;
679
+ const origKnowledge = this.repo.knowledge;
680
+ const origAgents = this.repo.agents;
681
+ const origAutomations = this.repo.automations;
682
+ // Swap in admin content so create() builds the prompt correctly
683
+ this.repo.skills = adminContent.skills;
684
+ this.repo.knowledge = adminContent.knowledge;
685
+ this.repo.agents = {
686
+ main: adminContent.agentPrompt ?? undefined,
687
+ simple: undefined,
688
+ subagents: [],
689
+ };
690
+ this.repo.automations = [];
691
+ let session;
692
+ try {
693
+ session = await this.create('admin');
694
+ }
695
+ finally {
696
+ // Restore original repo fields
697
+ this.repo.skills = origSkills;
698
+ this.repo.knowledge = origKnowledge;
699
+ this.repo.agents = origAgents;
700
+ this.repo.automations = origAutomations;
701
+ }
702
+ session.appId = 'admin';
703
+ // Register admin file tools (read/write/delete repo files)
704
+ try {
705
+ const { createReadRepoFileTool, createWriteRepoFileTool, createDeleteRepoFileTool } = await import('./admin-file-tools.js');
706
+ const repoRoot = this.repo.origin;
707
+ const upstream = session.config.getUpstreamConfig();
708
+ const toolRegistry = upstream.getToolRegistry();
709
+ toolRegistry.registerTool(createReadRepoFileTool(repoRoot)); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
710
+ toolRegistry.registerTool(createWriteRepoFileTool(repoRoot)); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
711
+ toolRegistry.registerTool(createDeleteRepoFileTool(repoRoot)); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
712
+ // Internal API tool — lets admin query eval results, health, context, etc.
713
+ if (getPort) {
714
+ const { createInternalApiTool } = await import('./admin-file-tools.js');
715
+ toolRegistry.registerTool(createInternalApiTool(getPort)); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
716
+ }
717
+ await session.geminiClient.setTools();
718
+ process.stderr.write('[ADMIN] Registered admin tools (file tools + internal_api)\n');
719
+ }
720
+ catch (err) {
721
+ const msg = err instanceof Error ? err.message : String(err);
722
+ process.stderr.write(`[ADMIN] Failed to register file tools: ${msg}\n`);
723
+ }
724
+ return session;
725
+ }
726
+ /**
727
+ * Get a persistent MCP manager for inspect/health operations.
728
+ * Lazy-initialized on first call, reused across requests.
729
+ */
730
+ async getInspectMcpManager() {
731
+ if (this.inspectMcpInitialized)
732
+ return this.inspectMcp;
733
+ this.inspectMcpInitialized = true;
734
+ if (!this.repo)
735
+ return undefined;
736
+ // Build MCP server configs from repo connections
737
+ const mcpServers = this.buildMcpConfigs(this.repo);
738
+ if (Object.keys(mcpServers).length === 0)
739
+ return undefined;
740
+ const manager = new McpManager();
741
+ try {
742
+ await manager.startServers(mcpServers);
743
+ this.inspectMcp = manager;
744
+ return manager;
745
+ }
746
+ catch (err) {
747
+ const msg = err instanceof Error ? err.message : String(err);
748
+ process.stderr.write(`[INSPECT] MCP initialization failed: ${msg}\n`);
749
+ this.inspectMcp = manager;
750
+ return manager;
751
+ }
752
+ }
753
+ /**
754
+ * Initialize MCP servers for a session from repo connections.
755
+ */
756
+ /**
757
+ * Initialize the shared MCP manager (once, reused across sessions).
758
+ * Avoids reconnecting MCP servers for every eval/judge/admin session.
759
+ */
760
+ async initSharedMcp(repo) {
761
+ const mcpServers = this.buildMcpConfigs(repo);
762
+ if (Object.keys(mcpServers).length === 0)
763
+ return;
764
+ const manager = new McpManager();
765
+ try {
766
+ await manager.startServers(mcpServers);
767
+ if (manager.connectedCount > 0) {
768
+ this.sharedMcpManager = manager;
769
+ }
770
+ }
771
+ catch (err) {
772
+ const msg = err instanceof Error ? err.message : String(err);
773
+ process.stderr.write(`[SESSION] MCP initialization failed: ${msg}\n`);
774
+ }
775
+ }
776
+ /**
777
+ * Build the list of upstream core tools to enable based on repo config.
778
+ * Only tools relevant to the Amodal runtime are included.
779
+ */
780
+ buildCoreToolsList(repo) {
781
+ const tools = [
782
+ // Always available
783
+ 'enter_plan_mode',
784
+ 'exit_plan_mode',
785
+ 'ask_user',
786
+ ];
787
+ // Shell execution (opt-in via config.sandbox.shellExec)
788
+ if (repo.config.sandbox?.shellExec) {
789
+ tools.push('shell');
790
+ }
791
+ return tools;
792
+ }
793
+ /**
794
+ * Build MCP server configs from repo connections.
795
+ */
796
+ buildMcpConfigs(repo) {
797
+ const mcpServers = {};
798
+ for (const [name, conn] of repo.connections) {
799
+ if (conn.spec.protocol === 'mcp') {
800
+ const resolvedHeaders = conn.spec.headers ? resolveEnvRefs(conn.spec.headers) : undefined;
801
+ const resolvedEnv = conn.spec.env ? resolveEnvRefs(conn.spec.env) : undefined;
802
+ mcpServers[name] = {
803
+ transport: conn.spec.transport ?? 'stdio',
804
+ command: conn.spec.command,
805
+ args: conn.spec.args,
806
+ env: resolvedEnv,
807
+ url: conn.spec.url,
808
+ headers: resolvedHeaders,
809
+ trust: conn.spec.trust,
810
+ };
811
+ }
812
+ }
813
+ if (repo.mcpServers) {
814
+ for (const [name, config] of Object.entries(repo.mcpServers)) {
815
+ if (!mcpServers[name]) {
816
+ mcpServers[name] = config;
817
+ }
818
+ }
819
+ }
820
+ return mcpServers;
821
+ }
398
822
  /**
399
823
  * Remove sessions that have been idle longer than the TTL.
400
824
  */
@@ -419,6 +843,18 @@ export class SessionManager {
419
843
  clearInterval(this.cleanupTimer);
420
844
  this.cleanupTimer = null;
421
845
  }
846
+ // Shutdown MCP managers for all sessions
847
+ for (const session of this.sessions.values()) {
848
+ if (session.mcpManager) {
849
+ await session.mcpManager.shutdown().catch(() => { });
850
+ }
851
+ }
852
+ // Shutdown persistent inspect MCP manager
853
+ if (this.inspectMcp) {
854
+ await this.inspectMcp.shutdown().catch(() => { });
855
+ this.inspectMcp = undefined;
856
+ this.inspectMcpInitialized = false;
857
+ }
422
858
  const ids = [...this.sessions.keys()];
423
859
  for (const id of ids) {
424
860
  await this.destroy(id);