fix: resolve FlowProcessor topic collisions, librarian timeout, tests

Fix critical bug where all FlowProcessor services shared the same spec
names ("request"/"response"), causing them to steal each other's NATS
topics. Now each service uses unique spec names matching the flow config
topic keys (e.g., "text-completion-request", "prompt-request",
"agent-request").

Fix librarian NATS consumer timeout (500ms → 2000ms, below NATS minimum).

Update seed-config and test-pipeline with correct flow topic mappings.
Add prompt template runner script.

Smoke test results: 11/11 passing (config CRUD, WebSocket, LLM,
librarian CRUD). Agent routing verified via manual curl test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-06 01:02:10 -05:00
parent 515fc0c264
commit 25d4227cb5
8 changed files with 147 additions and 98 deletions

View file

@ -13,12 +13,14 @@
"llm:openai": "tsx scripts/run-llm-openai.ts", "llm:openai": "tsx scripts/run-llm-openai.ts",
"test:pipeline": "tsx scripts/test-pipeline.ts", "test:pipeline": "tsx scripts/test-pipeline.ts",
"seed": "tsx scripts/seed-config.ts", "seed": "tsx scripts/seed-config.ts",
"prompt": "tsx scripts/run-prompt.ts",
"agent": "tsx scripts/run-agent.ts", "agent": "tsx scripts/run-agent.ts",
"librarian": "tsx scripts/run-librarian.ts", "librarian": "tsx scripts/run-librarian.ts",
"knowledge": "tsx scripts/run-knowledge.ts", "knowledge": "tsx scripts/run-knowledge.ts",
"flow-manager": "tsx scripts/run-flow-manager.ts" "flow-manager": "tsx scripts/run-flow-manager.ts"
}, },
"devDependencies": { "devDependencies": {
"nats": "^2.29.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"turbo": "^2.5.0", "turbo": "^2.5.0",
"typescript": "^5.8.0" "typescript": "^5.8.0"

View file

@ -4,102 +4,120 @@
* Python reference: trustgraph-base/trustgraph/base/llm_service.py * Python reference: trustgraph-base/trustgraph/base/llm_service.py
*/ */
import { FlowProcessor } from "../processor/flow-processor.js"; import {FlowProcessor} from "../processor/index.js";
import { ConsumerSpec } from "../spec/consumer-spec.js"; import {
import { ProducerSpec } from "../spec/producer-spec.js"; ConsumerSpec, ProducerSpec,
import { ParameterSpec } from "../spec/parameter-spec.js"; ParameterSpec
import type { ProcessorConfig } from "../processor/async-processor.js"; } from "../spec/index.js";
import type { FlowContext } from "../messaging/consumer.js"; import type {ProcessorConfig} from "../processor/index.js";
import type {FlowContext} from "../messaging/consumer.js";
import type { import type {
TextCompletionRequest, TextCompletionRequest,
TextCompletionResponse, TextCompletionResponse,
} from "../schema/messages.js"; } from "../schema/messages.js";
import type { LlmResult, LlmChunk } from "../schema/primitives.js"; import type {LlmResult, LlmChunk} from "../schema/index.js";
export abstract class LlmService extends FlowProcessor { export abstract class LlmService extends FlowProcessor {
constructor(config: ProcessorConfig) { protected constructor(config: ProcessorConfig) {
super(config); super(config);
this.registerSpecification( this.registerSpecification(
new ConsumerSpec<TextCompletionRequest>( new ConsumerSpec<TextCompletionRequest>(
"request", "text-completion-request",
this.onRequest.bind(this), this.onRequest.bind(this),
), ),
); );
this.registerSpecification(new ProducerSpec<TextCompletionResponse>("response")); this.registerSpecification(new ProducerSpec<TextCompletionResponse>("text-completion-response"));
this.registerSpecification(new ParameterSpec("model")); this.registerSpecification(new ParameterSpec("model"));
this.registerSpecification(new ParameterSpec("temperature")); this.registerSpecification(new ParameterSpec("temperature"));
} }
private async onRequest( private async onRequest(
msg: TextCompletionRequest, msg: TextCompletionRequest,
properties: Record<string, string>, properties: Record<string, string>,
flowCtx: FlowContext, flowCtx: FlowContext,
): Promise<void> { ): Promise<void> {
const requestId = properties.id; const requestId = properties.id;
if (!requestId) return; if (!requestId) return;
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("response"); const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
try { try {
if (msg.streaming && this.supportsStreaming()) { if (msg.streaming && this.supportsStreaming()) {
for await (const chunk of this.generateContentStream( for await (const chunk of this.generateContentStream(
msg.system, msg.system,
msg.prompt, msg.prompt,
msg.model, msg.model,
msg.temperature, msg.temperature,
)) { )) {
await responseProducer.send(requestId, { await responseProducer.send(
response: chunk.text, requestId,
model: chunk.model, {
inToken: chunk.inToken ?? undefined, response: chunk.text,
outToken: chunk.outToken ?? undefined, model: chunk.model,
endOfStream: chunk.isFinal, inToken: chunk.inToken ?? undefined,
}); outToken: chunk.outToken ?? undefined,
} endOfStream: chunk.isFinal,
} else { }
const result = await this.generateContent( );
msg.system, }
msg.prompt, } else {
msg.model, const result = await this.generateContent(
msg.temperature, msg.system,
); msg.prompt,
msg.model,
msg.temperature,
);
await responseProducer.send(requestId, { await responseProducer.send(
response: result.text, requestId,
model: result.model, {
inToken: result.inToken, response: result.text,
outToken: result.outToken, model: result.model,
endOfStream: true, inToken: result.inToken,
}); outToken: result.outToken,
} endOfStream: true,
} catch (err) { }
console.error(`[LlmService] Error processing request:`, err); );
}
} catch (err) {
console.error(
`[LlmService] Error processing request:`,
err
);
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error
await responseProducer.send(requestId, { ? err.message
response: "", : String(err);
error: { type: "llm-error", message }, await responseProducer.send(
endOfStream: true, requestId,
}); {
} response: "",
} error: {
type: "llm-error",
message
},
endOfStream: true,
}
);
}
}
abstract generateContent( abstract generateContent(
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): Promise<LlmResult>; ): Promise<LlmResult>;
abstract generateContentStream( abstract generateContentStream(
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): AsyncGenerator<LlmChunk>; ): AsyncGenerator<LlmChunk>;
supportsStreaming(): boolean { supportsStreaming(): boolean {
return false; return false;
} }
} }

View file

@ -47,11 +47,11 @@ export class AgentService extends FlowProcessor {
// Consumer: agent requests // Consumer: agent requests
this.registerSpecification( this.registerSpecification(
new ConsumerSpec<AgentRequest>("request", this.onRequest.bind(this)), new ConsumerSpec<AgentRequest>("agent-request", this.onRequest.bind(this)),
); );
// Producer: agent responses (streaming chunks) // Producer: agent responses (streaming chunks)
this.registerSpecification(new ProducerSpec<AgentResponse>("response")); this.registerSpecification(new ProducerSpec<AgentResponse>("agent-response"));
// Request-response clients for tool execution // Request-response clients for tool execution
this.registerSpecification( this.registerSpecification(
@ -94,7 +94,7 @@ export class AgentService extends FlowProcessor {
const requestId = properties.id; const requestId = properties.id;
if (!requestId) return; if (!requestId) return;
const responseProducer = flowCtx.flow.producer<AgentResponse>("response"); const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
try { try {
// Build tools from flow requestors // Build tools from flow requestors

View file

@ -83,14 +83,14 @@ export class LibrarianService extends AsyncProcessor {
while (this.running) { while (this.running) {
try { try {
// Poll librarian requests // Poll librarian requests
const libMsg = await this.libConsumer.receive(500); const libMsg = await this.libConsumer.receive(2000);
if (libMsg) { if (libMsg) {
await this.handleLibrarianMessage(libMsg); await this.handleLibrarianMessage(libMsg);
await this.libConsumer.acknowledge(libMsg); await this.libConsumer.acknowledge(libMsg);
} }
// Poll collection management requests // Poll collection management requests
const colMsg = await this.colConsumer.receive(500); const colMsg = await this.colConsumer.receive(2000);
if (colMsg) { if (colMsg) {
await this.handleCollectionMessage(colMsg); await this.handleCollectionMessage(colMsg);
await this.colConsumer.acknowledge(colMsg); await this.colConsumer.acknowledge(colMsg);

View file

@ -54,11 +54,11 @@ export class PromptTemplateService extends FlowProcessor {
this.registerSpecification( this.registerSpecification(
new ConsumerSpec<PromptRequest>( new ConsumerSpec<PromptRequest>(
"request", "prompt-request",
this.onRequest.bind(this), this.onRequest.bind(this),
), ),
); );
this.registerSpecification(new ProducerSpec<PromptResponse>("response")); this.registerSpecification(new ProducerSpec<PromptResponse>("prompt-response"));
this.registerConfigHandler(this.onPromptConfig.bind(this)); this.registerConfigHandler(this.onPromptConfig.bind(this));
@ -106,7 +106,7 @@ export class PromptTemplateService extends FlowProcessor {
const requestId = properties.id; const requestId = properties.id;
if (!requestId) return; if (!requestId) return;
const responseProducer = flowCtx.flow.producer<PromptResponse>("response"); const responseProducer = flowCtx.flow.producer<PromptResponse>("prompt-response");
try { try {
const template = this.templates.get(msg.name); const template = this.templates.get(msg.name);

14
ts/scripts/run-prompt.ts Normal file
View file

@ -0,0 +1,14 @@
/**
* Start the prompt template service.
*
* Usage: pnpm tsx scripts/run-prompt.ts
*
* Env:
* NATS_URL (default: nats://localhost:4222)
*/
import { run } from "../packages/flow/src/prompt/template.js";
run().catch((err) => {
console.error("Prompt service failed:", err);
process.exit(1);
});

View file

@ -96,8 +96,6 @@ async function main(): Promise<void> {
default: { default: {
topics: { topics: {
// LLM text completion // LLM text completion
"request": "tg.flow.text-completion-request",
"response": "tg.flow.text-completion-response",
"text-completion-request": "tg.flow.text-completion-request", "text-completion-request": "tg.flow.text-completion-request",
"text-completion-response": "tg.flow.text-completion-response", "text-completion-response": "tg.flow.text-completion-response",
// Prompt service // Prompt service
@ -112,6 +110,9 @@ async function main(): Promise<void> {
// Triples // Triples
"triples-request": "tg.flow.triples-request", "triples-request": "tg.flow.triples-request",
"triples-response": "tg.flow.triples-response", "triples-response": "tg.flow.triples-response",
// Agent
"agent-request": "tg.flow.agent-request",
"agent-response": "tg.flow.agent-response",
// Chunking pipeline // Chunking pipeline
"input": "tg.flow.chunk", "input": "tg.flow.chunk",
"output": "tg.flow.chunk", "output": "tg.flow.chunk",

View file

@ -127,15 +127,29 @@ async function testConfigDelete(): Promise<boolean> {
async function testPushFlowConfig(): Promise<boolean> { async function testPushFlowConfig(): Promise<boolean> {
try { try {
// Push a flow definition that LLM services will pick up // Push a full flow definition with all service topic mappings
const res = await post("/api/v1/config", { const res = await post("/api/v1/config", {
operation: "put", operation: "put",
keys: ["flows"], keys: ["flows"],
values: { values: {
default: { default: {
topics: { topics: {
request: "tg.flow.text-completion-request", "text-completion-request": "tg.flow.text-completion-request",
response: "tg.flow.text-completion-response", "text-completion-response": "tg.flow.text-completion-response",
"prompt-request": "tg.flow.prompt-request",
"prompt-response": "tg.flow.prompt-response",
"graph-rag-request": "tg.flow.graph-rag-request",
"graph-rag-response": "tg.flow.graph-rag-response",
"document-rag-request": "tg.flow.document-rag-request",
"document-rag-response": "tg.flow.document-rag-response",
"triples-request": "tg.flow.triples-request",
"triples-response": "tg.flow.triples-response",
"agent-request": "tg.flow.agent-request",
"agent-response": "tg.flow.agent-response",
"input": "tg.flow.chunk",
"output": "tg.flow.chunk",
"triples": "tg.flow.triples",
"entity-contexts": "tg.flow.entity-contexts",
}, },
}, },
}, },