Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 8
fix: AI agent routes never registered — kernel pre-injects core service fallbacks before plugin starts#1078
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
5d2b7229e0a502e75eb0b5dcf58bb083c75File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -22,6 +22,75 @@ interface RouteDefinition { | ||
| handler: (req: any) => Promise<any>; | ||
| } | ||
| /** | ||
| * Register a single RouteDefinition on the HTTP server. | ||
| * Returns true if the route was successfully registered. | ||
| */ | ||
| function mountRouteOnServer(route: RouteDefinition, server: IHttpServer, routePath: string): boolean { | ||
| const handler = async (req: any, res: any) => { | ||
| try { | ||
| const result = await route.handler({ | ||
| body: req.body, | ||
| params: req.params, | ||
| query: req.query, | ||
| }); | ||
| if (result.stream && result.events) { | ||
| // SSE streaming response | ||
| res.status(result.status); | ||
| // Apply headers from the route result if available | ||
| if (result.headers) { | ||
| for (const [k, v] of Object.entries(result.headers)) { | ||
| res.header(k, String(v)); | ||
| } | ||
| } else { | ||
| res.header('Content-Type', 'text/event-stream'); | ||
| res.header('Cache-Control', 'no-cache'); | ||
| res.header('Connection', 'keep-alive'); | ||
| } | ||
| // Write the stream — events are pre-encoded SSE strings | ||
| if (typeof res.write === 'function' && typeof res.end === 'function') { | ||
| for await (const event of result.events) { | ||
| res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`); | ||
| } | ||
| res.end(); | ||
| } else { | ||
| // Fallback: collect events into array | ||
| const events = []; | ||
| for await (const event of result.events) { | ||
| events.push(event); | ||
| } | ||
| res.json({ events }); | ||
| } | ||
| } else { | ||
| res.status(result.status); | ||
| if (result.body !== undefined) { | ||
| res.json(result.body); | ||
| } else { | ||
| res.end(); | ||
| } | ||
| } | ||
| } catch (err: any) { | ||
| errorResponse(err, res); | ||
| } | ||
| }; | ||
| const m = route.method.toLowerCase(); | ||
| if (m === 'get' && typeof server.get === 'function') { | ||
| server.get(routePath, handler); | ||
| return true; | ||
| } else if (m === 'post' && typeof server.post === 'function') { | ||
| server.post(routePath, handler); | ||
| return true; | ||
| } else if (m === 'delete' && typeof server.delete === 'function') { | ||
| server.delete(routePath, handler); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| /** | ||
| * Send an HttpDispatcherResult through IHttpResponse. | ||
| * Differentiates between handled, unhandled (404), and special results. | ||
| @@ -402,68 +471,33 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu | ||
| const routePath = route.path.startsWith('/api/v1') | ||
| ? route.path | ||
| : `${prefix}${route.path}`; | ||
| const handler = async (req: any, res: any) => { | ||
| try { | ||
| const result = await route.handler({ | ||
| body: req.body, | ||
| params: req.params, | ||
| query: req.query, | ||
| }); | ||
| if (result.stream && result.events) { | ||
| // SSE streaming response | ||
| res.status(result.status); | ||
| // Apply headers from the route result if available | ||
| if (result.headers) { | ||
| for (const [k, v] of Object.entries(result.headers)) { | ||
| res.header(k, v); | ||
| } | ||
| } else { | ||
| res.header('Content-Type', 'text/event-stream'); | ||
| res.header('Cache-Control', 'no-cache'); | ||
| res.header('Connection', 'keep-alive'); | ||
| } | ||
| // Write the stream — events are pre-encoded SSE strings | ||
| if (typeof res.write === 'function' && typeof res.end === 'function') { | ||
| for await (const event of result.events) { | ||
| res.write(typeof event === 'string' ? event : `data: ${JSON.stringify(event)}\n\n`); | ||
| } | ||
| res.end(); | ||
| } else { | ||
| // Fallback: collect events into array | ||
| const events = []; | ||
| for await (const event of result.events) { | ||
| events.push(event); | ||
| } | ||
| res.json({ events }); | ||
| } | ||
| } else { | ||
| res.status(result.status); | ||
| if (result.body !== undefined) { | ||
| res.json(result.body); | ||
| } else { | ||
| res.end(); | ||
| } | ||
| } | ||
| } catch (err: any) { | ||
| errorResponse(err, res); | ||
| } | ||
| }; | ||
| const m = route.method.toLowerCase(); | ||
| if (m === 'get' && typeof server.get === 'function') { | ||
| server.get(routePath, handler); | ||
| } else if (m === 'post' && typeof server.post === 'function') { | ||
| server.post(routePath, handler); | ||
| } else if (m === 'delete' && typeof server.delete === 'function') { | ||
| server.delete(routePath, handler); | ||
| } | ||
| mountRouteOnServer(route, server, routePath); | ||
| } | ||
| ctx.logger.info(`[Dispatcher] Registered ${routes.length} AI routes`); | ||
| }); | ||
| // ── Fallback: recover routes cached before hook was registered ── | ||
| // If AIServicePlugin.start() ran before DispatcherPlugin.start() | ||
| // (possible when plugin start order differs from registration order), | ||
| // the 'ai:routes' trigger fires with no listener. The AIServicePlugin | ||
| // caches the routes on the kernel as __aiRoutes (see AIServicePlugin.start()) | ||
| // as an internal cross-plugin protocol so we can recover them here. | ||
| // TODO: replace with a formal kernel.getCachedRoutes('ai') API in a future release. | ||
| const cachedRoutes = (kernel as any).__aiRoutes as RouteDefinition[] | undefined; | ||
| if (cachedRoutes && Array.isArray(cachedRoutes) && cachedRoutes.length > 0) { | ||
| let registered = 0; | ||
| for (const route of cachedRoutes) { | ||
| const routePath = route.path.startsWith('/api/v1') | ||
| ? route.path | ||
| : `${prefix}${route.path}`; | ||
| if (mountRouteOnServer(route, server, routePath)) { | ||
Comment on lines
+486
to
+493
CopilotAI | ||
| registered++; | ||
| } | ||
| } | ||
| if (registered > 0) { | ||
| ctx.logger.info(`[Dispatcher] Recovered ${registered} cached AI routes (hook timing fallback)`); | ||
| } | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -308,14 +308,12 @@ export class AIServicePlugin implements Plugin { | ||
| const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger); | ||
| // Build agent routes if metadata service is available | ||
| try { | ||
| const metadataService = ctx.getService<IMetadataService>('metadata'); | ||
| if (metadataService) { | ||
| const agentRuntime = new AgentRuntime(metadataService); | ||
| const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger); | ||
| routes.push(...agentRoutes); | ||
| } | ||
| } catch { | ||
| if (metadataService) { | ||
| const agentRuntime = new AgentRuntime(metadataService); | ||
| const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger); | ||
| routes.push(...agentRoutes); | ||
| ctx.logger.info(`[AI] Agent routes registered (${agentRoutes.length} routes)`); | ||
| } else { | ||
| ctx.logger.debug('[AI] Metadata service not available, skipping agent routes'); | ||
| } | ||
Comment on lines
308
to
318
CopilotAI | ||
CopilotAIApr 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
preInjectCoreFallbacks()changes bootstrap semantics (core fallbacks become available during pluginstart()), but there is no kernel-level test covering this contract. Consider adding a test that runs bootstrap withskipSystemValidation: falseand a plugin whosestart()callsctx.getService('metadata')without registering metadata in init; the test should assert it no longer throws and that a fallback is injected before Phase 2.