Skip to content

Commit 9e0a502

Browse files
Copilothotlong
andauthored
fix: AI agent routes not registered due to redundant ctx.getService('metadata') call
Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/35f9d46b-5132-451b-89f4-7dbee7066a1f Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 5d2b722 commit 9e0a502

3 files changed

Lines changed: 87 additions & 8 deletions

File tree

CHANGELOG.md

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

7373
### Fixed
74+
- **AI Chat agent selector missing `data_chat` and `metadata_assistant`** — Fixed `GET /api/v1/ai/agents`
75+
returning 404, which caused the Studio AI Chat panel to show only "General Chat". Root cause was a
76+
redundant second `ctx.getService('metadata')` call inside `AIServicePlugin.start()` that shadowed
77+
the outer resolved variable and failed silently, preventing `buildAgentRoutes()` from being called.
78+
Fix: reuse the already-resolved `metadataService` variable instead of re-fetching it. Additionally,
79+
added a fallback in `DispatcherPlugin.start()` that recovers AI routes from the `kernel.__aiRoutes`
80+
cache in case the `ai:routes` hook fires before the listener is registered (timing edge case).
7481
- **ObjectQLPlugin: cold-start metadata restoration**`ObjectQLPlugin.start()` now calls
7582
`protocol.loadMetaFromDb()` after driver initialization and before schema sync, restoring
7683
all persisted metadata (objects, views, apps, etc.) from the `sys_metadata` table into the

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,80 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
464464
}
465465
ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`);
466466
});
467+
468+
// ── Fallback: recover routes cached before hook was registered ──
469+
// If AIServicePlugin.start() ran before DispatcherPlugin.start()
470+
// (possible when plugin start order differs from registration order),
471+
// the 'ai:routes' trigger fires with no listener. The AIServicePlugin
472+
// caches the routes on the kernel as __aiRoutes so we can recover here.
473+
const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined;
474+
if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) {
475+
let registered = 0;
476+
for (const route of cachedRoutes) {
477+
const routePath = route.path.startsWith('/api/v1')
478+
? route.path
479+
: `${prefix}${route.path}`;
480+
481+
const handler = async (req: any, res: any) => {
482+
try {
483+
const result = await route.handler({
484+
body: req.body,
485+
params: req.params,
486+
query: req.query,
487+
});
488+
489+
if (result.stream && result.events) {
490+
res.status(result.status);
491+
if (result.headers) {
492+
for (const [k, v] of Object.entries(result.headers)) {
493+
res.header(k, v as string);
494+
}
495+
} else {
496+
res.header('Content-Type', 'text/event-stream');
497+
res.header('Cache-Control', 'no-cache');
498+
res.header('Connection', 'keep-alive');
499+
}
500+
if (typeof res.write === 'function' && typeof res.end === 'function') {
501+
for await (const event of result.events) {
502+
res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`);
503+
}
504+
res.end();
505+
} else {
506+
const events = [];
507+
for await (const event of result.events) {
508+
events.push(event);
509+
}
510+
res.json({ events });
511+
}
512+
} else {
513+
res.status(result.status);
514+
if (result.body !== undefined) {
515+
res.json(result.body);
516+
} else {
517+
res.end();
518+
}
519+
}
520+
} catch (err: any) {
521+
errorResponse(err, res);
522+
}
523+
};
524+
525+
const m = route.method.toLowerCase();
526+
if (m === 'get' && typeof server.get === 'function') {
527+
server.get(routePath, handler);
528+
registered++;
529+
} else if (m === 'post' && typeof server.post === 'function') {
530+
server.post(routePath, handler);
531+
registered++;
532+
} else if (m === 'delete' && typeof server.delete === 'function') {
533+
server.delete(routePath, handler);
534+
registered++;
535+
}
536+
}
537+
if (registered > 0) {
538+
ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`);
539+
}
540+
}
467541
},
468542
};
469543
}

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)