2026-04-05 21:09:33 -05:00
|
|
|
/**
|
|
|
|
|
* Dispatcher manager — routes requests to backend services via pub/sub.
|
|
|
|
|
*
|
2026-04-05 22:44:45 -05:00
|
|
|
* Maintains a service registry mapping service names to NATS topic pairs.
|
|
|
|
|
* Applies wire format translation on requests (client → internal) and
|
|
|
|
|
* reverse translation on responses (internal → client).
|
|
|
|
|
*
|
2026-04-05 21:09:33 -05:00
|
|
|
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { NatsBackend, RequestResponse, type PubSubBackend } from "@trustgraph/base";
|
|
|
|
|
import type { GatewayConfig } from "../server.js";
|
2026-04-05 22:44:45 -05:00
|
|
|
import { translateRequest, translateResponse } from "./serialize.js";
|
2026-04-05 21:09:33 -05:00
|
|
|
|
|
|
|
|
export type Responder = (response: unknown, complete: boolean) => Promise<void>;
|
|
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
// ---------- Service registry ----------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Flow-scoped request/response services.
|
|
|
|
|
* These are resolved within a specific flow's interface definitions.
|
|
|
|
|
* Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response
|
|
|
|
|
*/
|
|
|
|
|
const FLOW_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
|
|
|
|
|
["agent", { request: "agent-request", response: "agent-response" }],
|
|
|
|
|
["text-completion", { request: "text-completion-request", response: "text-completion-response" }],
|
|
|
|
|
["prompt", { request: "prompt-request", response: "prompt-response" }],
|
|
|
|
|
["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }],
|
|
|
|
|
["document-rag", { request: "document-rag-request", response: "document-rag-response" }],
|
|
|
|
|
["embeddings", { request: "embeddings-request", response: "embeddings-response" }],
|
|
|
|
|
["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }],
|
|
|
|
|
["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }],
|
|
|
|
|
["triples", { request: "triples-request", response: "triples-response" }],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global services (not flow-scoped).
|
|
|
|
|
* These always use fixed topics regardless of which flow is active.
|
|
|
|
|
*/
|
|
|
|
|
const GLOBAL_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
|
|
|
|
|
["config", { request: "config-request", response: "config-response" }],
|
|
|
|
|
["flow", { request: "flow-request", response: "flow-response" }],
|
|
|
|
|
["librarian", { request: "librarian-request", response: "librarian-response" }],
|
|
|
|
|
["knowledge", { request: "knowledge-request", response: "knowledge-response" }],
|
|
|
|
|
["collection-management", { request: "collection-management-request", response: "collection-management-response" }],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Services that support streaming responses (multiple messages per request).
|
|
|
|
|
* The completion flag is determined by checking for end-of-stream markers.
|
|
|
|
|
*/
|
|
|
|
|
const STREAMING_SERVICES = new Set([
|
|
|
|
|
"agent",
|
|
|
|
|
"text-completion",
|
|
|
|
|
"graph-rag",
|
|
|
|
|
"document-rag",
|
|
|
|
|
"triples",
|
|
|
|
|
"knowledge",
|
|
|
|
|
"librarian",
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
function topicName(name: string): string {
|
|
|
|
|
return `tg.flow.${name}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- Manager ----------
|
|
|
|
|
|
2026-04-05 21:09:33 -05:00
|
|
|
export class DispatcherManager {
|
2026-04-05 22:52:40 -05:00
|
|
|
private readonly pubsub: PubSubBackend;
|
2026-04-05 21:09:33 -05:00
|
|
|
private requestors = new Map<string, RequestResponse<unknown, unknown>>();
|
|
|
|
|
|
2026-04-05 22:52:40 -05:00
|
|
|
constructor(config: GatewayConfig) {
|
2026-04-05 21:09:33 -05:00
|
|
|
this.pubsub = new NatsBackend(config.natsUrl ?? "nats://localhost:4222");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async start(): Promise<void> {
|
2026-04-05 22:44:45 -05:00
|
|
|
// Requestors are created on demand when first accessed
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async stop(): Promise<void> {
|
|
|
|
|
for (const rr of this.requestors.values()) {
|
|
|
|
|
await rr.stop();
|
|
|
|
|
}
|
|
|
|
|
await this.pubsub.close();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
// ---------- Internal helpers ----------
|
|
|
|
|
|
2026-04-05 21:09:33 -05:00
|
|
|
private async getRequestor(
|
|
|
|
|
requestTopic: string,
|
|
|
|
|
responseTopic: string,
|
|
|
|
|
key: string,
|
|
|
|
|
): Promise<RequestResponse<unknown, unknown>> {
|
|
|
|
|
let rr = this.requestors.get(key);
|
|
|
|
|
if (!rr) {
|
|
|
|
|
rr = new RequestResponse({
|
|
|
|
|
pubsub: this.pubsub,
|
|
|
|
|
requestTopic,
|
|
|
|
|
responseTopic,
|
|
|
|
|
subscription: `gateway-${key}`,
|
|
|
|
|
});
|
|
|
|
|
await rr.start();
|
|
|
|
|
this.requestors.set(key, rr);
|
|
|
|
|
}
|
|
|
|
|
return rr;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
private resolveGlobalTopics(
|
2026-04-05 21:09:33 -05:00
|
|
|
kind: string,
|
2026-04-05 22:44:45 -05:00
|
|
|
): { requestTopic: string; responseTopic: string } {
|
|
|
|
|
const entry = GLOBAL_SERVICES.get(kind);
|
|
|
|
|
if (entry) {
|
|
|
|
|
return {
|
|
|
|
|
requestTopic: topicName(entry.request),
|
|
|
|
|
responseTopic: topicName(entry.response),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// Fallback: derive from kind name directly
|
|
|
|
|
return {
|
|
|
|
|
requestTopic: topicName(`${kind}-request`),
|
|
|
|
|
responseTopic: topicName(`${kind}-response`),
|
|
|
|
|
};
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
private resolveFlowTopics(
|
|
|
|
|
kind: string,
|
|
|
|
|
): { requestTopic: string; responseTopic: string } {
|
|
|
|
|
const entry = FLOW_SERVICES.get(kind);
|
|
|
|
|
if (entry) {
|
|
|
|
|
return {
|
|
|
|
|
requestTopic: topicName(entry.request),
|
|
|
|
|
responseTopic: topicName(entry.response),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
// Fallback: derive from kind name directly
|
|
|
|
|
return {
|
|
|
|
|
requestTopic: topicName(`${kind}-request`),
|
|
|
|
|
responseTopic: topicName(`${kind}-response`),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Determine whether a response is the final one in a streaming sequence.
|
|
|
|
|
* Checks for various end-of-stream markers used by different services.
|
|
|
|
|
*/
|
|
|
|
|
private isComplete(response: unknown): boolean {
|
|
|
|
|
if (typeof response !== "object" || response === null) return true;
|
|
|
|
|
const res = response as Record<string, unknown>;
|
|
|
|
|
return (
|
|
|
|
|
!!res.complete ||
|
|
|
|
|
!!res.endOfStream ||
|
|
|
|
|
!!res.endOfSession ||
|
|
|
|
|
!!res.end_of_stream ||
|
|
|
|
|
!!res.end_of_session ||
|
feat: add schema foundation for document pipeline, agent, and deployment
Add missing topics (librarian, knowledge, collection-management, flow),
pipeline message types (TextDocument, Chunk, Triples, EntityContexts),
service message types (Librarian, Knowledge, Collection, Flow CRUD),
and update AgentResponse for streaming chunk format.
Add RequestResponseSpec enabling flow-scoped request/response calls
(needed by knowledge extraction and agent services). Add requestor
registry to Flow class with proper lifecycle management.
Add end_of_dialog to gateway's isComplete() check for agent streaming.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 00:11:29 -05:00
|
|
|
!!res.end_of_dialog ||
|
2026-04-05 22:44:45 -05:00
|
|
|
!!res.eos ||
|
|
|
|
|
// error responses are always final
|
|
|
|
|
!!res.error
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------- Global service dispatch ----------
|
|
|
|
|
|
|
|
|
|
async dispatchGlobalService(
|
2026-04-05 21:09:33 -05:00
|
|
|
kind: string,
|
|
|
|
|
request: Record<string, unknown>,
|
|
|
|
|
): Promise<unknown> {
|
2026-04-05 22:44:45 -05:00
|
|
|
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
|
|
|
|
|
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
|
|
|
|
|
|
|
|
|
|
const translated = translateRequest(kind, request);
|
|
|
|
|
const response = await rr.request(translated);
|
|
|
|
|
return translateResponse(kind, response);
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async dispatchGlobalServiceStreaming(
|
|
|
|
|
kind: string,
|
|
|
|
|
request: Record<string, unknown>,
|
|
|
|
|
responder: Responder,
|
|
|
|
|
): Promise<void> {
|
2026-04-05 22:44:45 -05:00
|
|
|
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
|
2026-04-05 21:09:33 -05:00
|
|
|
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
|
2026-04-05 22:44:45 -05:00
|
|
|
const translated = translateRequest(kind, request);
|
2026-04-05 21:09:33 -05:00
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
await rr.request(translated, {
|
2026-04-05 21:09:33 -05:00
|
|
|
recipient: async (response) => {
|
2026-04-05 22:44:45 -05:00
|
|
|
const translatedRes = translateResponse(kind, response);
|
|
|
|
|
const complete = this.isComplete(translatedRes);
|
|
|
|
|
await responder(translatedRes, complete);
|
2026-04-05 21:09:33 -05:00
|
|
|
return complete;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
// ---------- Flow-scoped service dispatch ----------
|
|
|
|
|
|
|
|
|
|
async dispatchFlowService(
|
|
|
|
|
flow: string,
|
|
|
|
|
kind: string,
|
|
|
|
|
request: Record<string, unknown>,
|
|
|
|
|
): Promise<unknown> {
|
|
|
|
|
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
|
|
|
|
|
const rr = await this.getRequestor(
|
|
|
|
|
requestTopic,
|
|
|
|
|
responseTopic,
|
|
|
|
|
`flow:${flow}:${kind}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const translated = translateRequest(kind, request);
|
|
|
|
|
const response = await rr.request(translated);
|
|
|
|
|
return translateResponse(kind, response);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 21:09:33 -05:00
|
|
|
async dispatchFlowServiceStreaming(
|
|
|
|
|
flow: string,
|
|
|
|
|
kind: string,
|
|
|
|
|
request: Record<string, unknown>,
|
|
|
|
|
responder: Responder,
|
|
|
|
|
): Promise<void> {
|
2026-04-05 22:44:45 -05:00
|
|
|
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
|
|
|
|
|
const rr = await this.getRequestor(
|
|
|
|
|
requestTopic,
|
|
|
|
|
responseTopic,
|
|
|
|
|
`flow:${flow}:${kind}`,
|
|
|
|
|
);
|
|
|
|
|
const translated = translateRequest(kind, request);
|
2026-04-05 21:09:33 -05:00
|
|
|
|
2026-04-05 22:44:45 -05:00
|
|
|
await rr.request(translated, {
|
2026-04-05 21:09:33 -05:00
|
|
|
recipient: async (response) => {
|
2026-04-05 22:44:45 -05:00
|
|
|
const translatedRes = translateResponse(kind, response);
|
|
|
|
|
const complete = this.isComplete(translatedRes);
|
|
|
|
|
await responder(translatedRes, complete);
|
2026-04-05 21:09:33 -05:00
|
|
|
return complete;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-05 22:44:45 -05:00
|
|
|
|
|
|
|
|
// ---------- Static introspection ----------
|
|
|
|
|
|
|
|
|
|
static get flowServiceNames(): readonly string[] {
|
|
|
|
|
return [...FLOW_SERVICES.keys()];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static get globalServiceNames(): readonly string[] {
|
|
|
|
|
return [...GLOBAL_SERVICES.keys()];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static isStreamingService(kind: string): boolean {
|
|
|
|
|
return STREAMING_SERVICES.has(kind);
|
|
|
|
|
}
|
2026-04-05 21:09:33 -05:00
|
|
|
}
|