Skip to content

Commit 68a673c

Browse files
authored
Merge pull request #1078 from objectstack-ai/copilot/fix-agent-registration-issue
2 parents db30cd1 + b083c75 commit 68a673c

File tree

4 files changed

+143
-67
lines changed

4 files changed

+143
-67
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8080
match the current monorepo layout.
8181

8282
### Fixed
83+
- **AI Chat agent selector missing `data_chat` and `metadata_assistant`** — Fixed `GET /api/v1/ai/agents`
84+
returning 404, which caused the Studio AI Chat panel to show only "General Chat". There were two
85+
root causes addressed by this fix:
86+
1. **Kernel bootstrap timing** (`packages/core/src/kernel.ts`): 'core' service in-memory fallbacks
87+
(e.g. the 'metadata' service) were only injected in `validateSystemRequirements()` which runs
88+
AFTER all plugin `start()` methods execute. This meant `ctx.getService('metadata')` always threw
89+
during `AIServicePlugin.start()` when no explicit `MetadataPlugin` was loaded. Fix: added
90+
`preInjectCoreFallbacks()` called between Phase 1 (init) and Phase 2 (start), ensuring all core
91+
service fallbacks are available before any plugin's `start()` runs.
92+
2. **Shadowed variable** (`packages/services/service-ai/src/plugin.ts`): a redundant second
93+
`ctx.getService('metadata')` call declared a new `const metadataService` that shadowed the outer
94+
`let metadataService` and failed silently, preventing `buildAgentRoutes()` from being called even
95+
if the metadata service was available. Fix: reuse the already-resolved outer variable.
96+
Additionally, added a fallback in `DispatcherPlugin.start()` that recovers AI routes from the
97+
`kernel.__aiRoutes` cache in case the `ai:routes` hook fires before the listener is registered
98+
(timing edge case).
8399
- **ObjectQLPlugin: cold-start metadata restoration**`ObjectQLPlugin.start()` now calls
84100
`protocol.loadMetaFromDb()` after driver initialization and before schema sync, restoring
85101
all persisted metadata (objects, views, apps, etc.) from the `sys_metadata` table into the

packages/core/src/kernel.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,28 @@ export class ObjectKernel {
213213
return this;
214214
}
215215

216+
/**
217+
* Pre-inject in-memory fallbacks for 'core' services that were not registered
218+
* by plugins during Phase 1. Called before Phase 2 so that all core services
219+
* (e.g. 'metadata', 'cache', 'queue') are resolvable via ctx.getService()
220+
* when plugin start() methods execute.
221+
*/
222+
private preInjectCoreFallbacks() {
223+
if (this.config.skipSystemValidation) return;
224+
for (const [serviceName, criticality] of Object.entries(ServiceRequirementDef)) {
225+
if (criticality !== 'core') continue;
226+
const hasService = this.services.has(serviceName) || this.pluginLoader.hasService(serviceName);
227+
if (!hasService) {
228+
const factory = CORE_FALLBACK_FACTORIES[serviceName];
229+
if (factory) {
230+
const fallback = factory();
231+
this.registerService(serviceName, fallback);
232+
this.logger.debug(`[Kernel] Pre-injected in-memory fallback for '${serviceName}' before Phase 2`);
233+
}
234+
}
235+
}
236+
}
237+
216238
/**
217239
* Validate Critical System Requirements
218240
*/
@@ -291,6 +313,12 @@ export class ObjectKernel {
291313
await this.initPluginWithTimeout(plugin);
292314
}
293315

316+
// Pre-inject in-memory fallbacks for 'core' services that were not
317+
// registered by any plugin during Phase 1. This ensures services like
318+
// 'metadata', 'cache', 'queue', etc. are always available when plugins
319+
// call ctx.getService() during their start() methods.
320+
this.preInjectCoreFallbacks();
321+
294322
// Phase 2: Start - Plugins execute business logic
295323
this.logger.info('Phase 2: Start plugins');
296324
this.state = 'running';

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 93 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,75 @@ interface RouteDefinition {
2222
handler: (req: any) => Promise<any>;
2323
}
2424

25+
/**
26+
* Register a single RouteDefinition on the HTTP server.
27+
* Returns true if the route was successfully registered.
28+
*/
29+
function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean {
30+
const handler = async (req: any, res: any) => {
31+
try {
32+
const result = await route.handler({
33+
body: req.body,
34+
params: req.params,
35+
query: req.query,
36+
});
37+
38+
if (result.stream && result.events) {
39+
// SSE streaming response
40+
res.status(result.status);
41+
42+
// Apply headers from the route result if available
43+
if (result.headers) {
44+
for (const [k, v] of Object.entries(result.headers)) {
45+
res.header(k, String(v));
46+
}
47+
} else {
48+
res.header('Content-Type', 'text/event-stream');
49+
res.header('Cache-Control', 'no-cache');
50+
res.header('Connection', 'keep-alive');
51+
}
52+
53+
// Write the stream — events are pre-encoded SSE strings
54+
if (typeof res.write === 'function' && typeof res.end === 'function') {
55+
for await (const event of result.events) {
56+
res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
57+
}
58+
res.end();
59+
} else {
60+
// Fallback: collect events into array
61+
const events = [];
62+
for await (const event of result.events) {
63+
events.push(event);
64+
}
65+
res.json({ events });
66+
}
67+
} else {
68+
res.status(result.status);
69+
if (result.body !== undefined) {
70+
res.json(result.body);
71+
} else {
72+
res.end();
73+
}
74+
}
75+
} catch (err: any) {
76+
errorResponse(err, res);
77+
}
78+
};
79+
80+
const m = route.method.toLowerCase();
81+
if (m === 'get' && typeof server.get === 'function') {
82+
server.get(routePath, handler);
83+
return true;
84+
} else if (m === 'post' && typeof server.post === 'function') {
85+
server.post(routePath, handler);
86+
return true;
87+
} else if (m === 'delete' && typeof server.delete === 'function') {
88+
server.delete(routePath, handler);
89+
return true;
90+
}
91+
return false;
92+
}
93+
2594
/**
2695
* Send an HttpDispatcherResult through IHttpResponse.
2796
* Differentiates between handled, unhandled (404), and special results.
@@ -402,68 +471,33 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
402471
const routePath = route.path.startsWith('/api/v1')
403472
? route.path
404473
: `${prefix}${route.path}`;
405-
406-
const handler = async (req: any, res: any) => {
407-
try {
408-
const result = await route.handler({
409-
body: req.body,
410-
params: req.params,
411-
query: req.query,
412-
});
413-
414-
if (result.stream && result.events) {
415-
// SSE streaming response
416-
res.status(result.status);
417-
418-
// Apply headers from the route result if available
419-
if (result.headers) {
420-
for (const [k, v] of Object.entries(result.headers)) {
421-
res.header(k, v);
422-
}
423-
} else {
424-
res.header('Content-Type', 'text/event-stream');
425-
res.header('Cache-Control', 'no-cache');
426-
res.header('Connection', 'keep-alive');
427-
}
428-
429-
// Write the stream — events are pre-encoded SSE strings
430-
if (typeof res.write === 'function' && typeof res.end === 'function') {
431-
for await (const event of result.events) {
432-
res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
433-
}
434-
res.end();
435-
} else {
436-
// Fallback: collect events into array
437-
const events = [];
438-
for await (const event of result.events) {
439-
events.push(event);
440-
}
441-
res.json({ events });
442-
}
443-
} else {
444-
res.status(result.status);
445-
if (result.body !== undefined) {
446-
res.json(result.body);
447-
} else {
448-
res.end();
449-
}
450-
}
451-
} catch (err: any) {
452-
errorResponse(err, res);
453-
}
454-
};
455-
456-
const m = route.method.toLowerCase();
457-
if (m === 'get' && typeof server.get === 'function') {
458-
server.get(routePath, handler);
459-
} else if (m === 'post' && typeof server.post === 'function') {
460-
server.post(routePath, handler);
461-
} else if (m === 'delete' && typeof server.delete === 'function') {
462-
server.delete(routePath, handler);
463-
}
474+
mountRouteOnServer(route, server, routePath);
464475
}
465476
ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
466477
});
478+
479+
// ── Fallback: recover routes cached before hook was registered ──
480+
// If AIServicePlugin.start() ran before DispatcherPlugin.start()
481+
// (possible when plugin start order differs from registration order),
482+
// the 'ai:routes' trigger fires with no listener. The AIServicePlugin
483+
// caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start())
484+
// as an internal cross-plugin protocol so we can recover them here.
485+
// TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release.
486+
const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined;
487+
if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) {
488+
let registered = 0;
489+
for (const route of cachedRoutes) {
490+
const routePath = route.path.startsWith('/api/v1')
491+
? route.path
492+
: `${prefix}${route.path}`;
493+
if (mountRouteOnServer(route, server, routePath)) {
494+
registered++;
495+
}
496+
}
497+
if (registered > 0) {
498+
ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
499+
}
500+
}
467501
},
468502
};
469503
}

packages/services/service-ai/src/plugin.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -308,14 +308,12 @@ export class AIServicePlugin implements Plugin {
308308
const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
309309

310310
// Build agent routes if metadata service is available
311-
try {
312-
const metadataService = ctx.getService<IMetadataService>('metadata');
313-
if (metadataService) {
314-
const agentRuntime = new AgentRuntime(metadataService);
315-
const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
316-
routes.push(...agentRoutes);
317-
}
318-
} catch {
311+
if (metadataService) {
312+
const agentRuntime = new AgentRuntime(metadataService);
313+
const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
314+
routes.push(...agentRoutes);
315+
ctx.logger.info(`[AI] Agent routes registered (${agentRoutes.length} routes)`);
316+
} else {
319317
ctx.logger.debug('[AI] Metadata service not available, skipping agent routes');
320318
}
321319

0 commit comments

Comments
 (0)