This commit is contained in:
elpresidank 2026-05-12 08:06:58 -05:00
parent e8c7a4f6e0
commit ffd97375a8
160 changed files with 6704 additions and 1895 deletions

View file

@ -22,6 +22,7 @@ import {
type ToolRequest,
type ToolResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
interface McpServiceConfig {
url: string;
@ -36,7 +37,7 @@ export class McpToolService extends FlowProcessor {
super(config);
this.registerSpecification(
new ConsumerSpec<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
ConsumerSpec.fromPromise<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
);
this.registerSpecification(new ProducerSpec<ToolResponse>("mcp-tool-response"));
@ -77,14 +78,16 @@ export class McpToolService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<ToolResponse>("mcp-tool-response");
try {
const result = await this.invokeTool(
msg.name,
msg.parameters ? JSON.parse(msg.parameters) : {},
msg.parameters !== undefined && msg.parameters.length > 0
? JSON.parse(msg.parameters) as Record<string, unknown>
: {},
);
if (typeof result === "string") {
@ -110,7 +113,7 @@ export class McpToolService extends FlowProcessor {
}
const svcConfig = this.mcpServices[name];
if (!svcConfig.url) {
if (svcConfig.url.length === 0) {
throw new Error(`MCP service "${name}" URL not defined`);
}
@ -118,7 +121,7 @@ export class McpToolService extends FlowProcessor {
// Build headers with optional bearer token
const headers: Record<string, string> = {};
if (svcConfig["auth-token"]) {
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`;
}
@ -133,7 +136,7 @@ export class McpToolService extends FlowProcessor {
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
try {
await client.connect(transport);
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
const result = await client.callTool({
name: remoteName,
@ -141,11 +144,11 @@ export class McpToolService extends FlowProcessor {
});
// Extract response — prefer structured content, fall back to text
if (result.structuredContent) {
if (result.structuredContent !== undefined && result.structuredContent !== null) {
return result.structuredContent;
}
if (result.content && Array.isArray(result.content)) {
if (result.content !== undefined && Array.isArray(result.content)) {
return result.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
@ -158,3 +161,8 @@ export class McpToolService extends FlowProcessor {
}
}
}
export const program = makeProcessorProgram({
id: "mcp-tool",
make: (config) => new McpToolService(config),
});

View file

@ -25,13 +25,22 @@ const MAX_MARKER_LEN = Math.max(...MARKERS.map((m) => m.prefix.length));
export class StreamingReActParser {
private state: ReActState = "initial";
private buffer = "";
private onThought: (text: string) => void;
private onAction: (name: string) => void;
private onActionInput: (input: string) => void;
private onFinalAnswer: (text: string) => void;
constructor(
private onThought: (text: string) => void,
private onAction: (name: string) => void,
private onActionInput: (input: string) => void,
private onFinalAnswer: (text: string) => void,
) {}
onThought: (text: string) => void,
onAction: (name: string) => void,
onActionInput: (input: string) => void,
onFinalAnswer: (text: string) => void,
) {
this.onThought = onThought;
this.onAction = onAction;
this.onActionInput = onActionInput;
this.onFinalAnswer = onFinalAnswer;
}
/**
* Feed a chunk of LLM output text into the parser.

View file

@ -36,6 +36,7 @@ import {
type ToolRequest,
type ToolResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import {
createKnowledgeQueryTool,
@ -45,7 +46,7 @@ import {
type ExplainData,
} from "./tools.js";
import { buildReActPrompt } from "./prompt.js";
import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js";
import { filterToolsByGroupAndState } from "../tool-filter.js";
import type { AgentTool, ToolArg } from "./types.js";
const MAX_ITERATIONS = 10;
@ -59,7 +60,7 @@ export class AgentService extends FlowProcessor {
// Consumer: agent requests
this.registerSpecification(
new ConsumerSpec<AgentRequest>("agent-request", this.onRequest.bind(this)),
ConsumerSpec.fromPromise<AgentRequest>("agent-request", this.onRequest.bind(this)),
);
// Producer: agent responses (streaming chunks)
@ -132,11 +133,12 @@ export class AgentService extends FlowProcessor {
for (const [_toolId, toolValue] of Object.entries(toolConfig)) {
try {
const data = JSON.parse(toolValue) as Record<string, unknown>;
const implType = data["type"] as string;
const name = data["name"] as string;
const description = data["description"] as string ?? "";
const implType = typeof data["type"] === "string" ? data["type"] : "";
const name = typeof data["name"] === "string" ? data["name"] : "";
const description =
typeof data["description"] === "string" ? data["description"] : "";
if (!name) {
if (name.length === 0) {
console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`);
continue;
}
@ -148,7 +150,10 @@ export class AgentService extends FlowProcessor {
// Will be wired to requestor at request time
tool = {
name,
description: description || "Query the knowledge graph for information about entities and their relationships.",
description:
description.length > 0
? description
: "Query the knowledge graph for information about entities and their relationships.",
args: [{ name: "question", type: "string", description: "The question to ask" }],
config: data,
execute: async () => "", // placeholder — wired at request time
@ -158,7 +163,10 @@ export class AgentService extends FlowProcessor {
case "document-query":
tool = {
name,
description: description || "Search documents for relevant information.",
description:
description.length > 0
? description
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config: data,
execute: async () => "",
@ -168,7 +176,10 @@ export class AgentService extends FlowProcessor {
case "triples-query":
tool = {
name,
description: description || "Query for specific triples in the knowledge graph.",
description:
description.length > 0
? description
: "Query for specific triples in the knowledge graph.",
args: [
{ name: "subject", type: "string", description: "Subject entity (optional)" },
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
@ -203,7 +214,7 @@ export class AgentService extends FlowProcessor {
continue;
}
if (tool) {
if (tool !== null) {
tools.push(tool);
console.log(`[AgentService] Registered tool: ${name} (${implType})`);
}
@ -276,7 +287,7 @@ export class AgentService extends FlowProcessor {
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
@ -290,7 +301,7 @@ export class AgentService extends FlowProcessor {
// Build tools — config-driven or hardcoded fallback
let tools: AgentTool[];
if (this.configuredTools) {
if (this.configuredTools !== null) {
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
} else {
// Hardcoded fallback (backward compat)
@ -339,7 +350,7 @@ export class AgentService extends FlowProcessor {
prompt: conversation,
});
if (llmResponse.error) {
if (llmResponse.error !== undefined) {
await responseProducer.send(requestId, {
chunk_type: "error",
content: `LLM error: ${llmResponse.error.message}`,
@ -354,7 +365,7 @@ export class AgentService extends FlowProcessor {
const parsed = parseReActResponse(text);
// Send thought chunk
if (parsed.thought) {
if (parsed.thought.length > 0) {
await responseProducer.send(requestId, {
chunk_type: "thought",
content: parsed.thought,
@ -363,7 +374,7 @@ export class AgentService extends FlowProcessor {
}
// If we got a final answer, emit explain events then send the answer
if (parsed.finalAnswer) {
if (parsed.finalAnswer.length > 0) {
// Emit explain events collected from tool calls
for (const explain of explainEvents) {
await responseProducer.send(requestId, {
@ -384,11 +395,11 @@ export class AgentService extends FlowProcessor {
}
// Execute tool if action was specified
if (parsed.action && parsed.actionInput) {
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
const tool = tools.find((t) => t.name === parsed.action);
let observation: string;
if (tool) {
if (tool !== undefined) {
try {
observation = await tool.execute(parsed.actionInput);
} catch (err) {
@ -407,7 +418,7 @@ export class AgentService extends FlowProcessor {
// Append the full exchange to conversation for the next iteration
conversation += `\n${text}\nObservation: ${observation}\n`;
} else if (!parsed.finalAnswer) {
} else if (parsed.finalAnswer.length === 0) {
// LLM didn't produce a valid action or final answer -- nudge it
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
}
@ -464,30 +475,31 @@ function parseReActResponse(text: string): {
// Everything from "Final Answer:" to end of text is the answer
const firstLine = trimmed.slice("Final Answer:".length).trim();
const remainingLines = lines.slice(i + 1).join("\n").trim();
finalAnswer = firstLine + (remainingLines ? "\n" + remainingLines : "");
finalAnswer =
firstLine + (remainingLines.length > 0 ? "\n" + remainingLines : "");
break;
} else if (trimmed.startsWith("Thought:")) {
currentSection = "thought";
const content = trimmed.slice("Thought:".length).trim();
if (content) {
thought += (thought ? "\n" : "") + content;
if (content.length > 0) {
thought += (thought.length > 0 ? "\n" : "") + content;
}
} else if (trimmed.startsWith("Action Input:")) {
currentSection = "action_input";
const content = trimmed.slice("Action Input:".length).trim();
if (content) {
if (content.length > 0) {
actionInput += content;
}
} else if (trimmed.startsWith("Action:")) {
currentSection = "action";
const content = trimmed.slice("Action:".length).trim();
if (content) {
if (content.length > 0) {
action = content;
}
} else if (trimmed.startsWith("Observation:")) {
// Stop processing -- observations are injected by us, not the LLM
currentSection = null;
} else if (trimmed.length > 0 && currentSection) {
} else if (trimmed.length > 0 && currentSection !== null) {
// Continuation line for current section
switch (currentSection) {
case "thought":
@ -512,6 +524,11 @@ function parseReActResponse(text: string): {
};
}
export const program = makeProcessorProgram({
id: "agent",
make: (config) => new AgentService(config),
});
export async function run(): Promise<void> {
await AgentService.launch("agent");
}

View file

@ -6,7 +6,7 @@
*/
import type {
RequestResponse,
FlowRequestor,
GraphRagRequest,
GraphRagResponse,
DocumentRagRequest,
@ -68,7 +68,7 @@ export interface ExplainData {
* Query the knowledge graph for information about entities and their relationships.
*/
export function createKnowledgeQueryTool(
client: RequestResponse<GraphRagRequest, GraphRagResponse>,
client: FlowRequestor<GraphRagRequest, GraphRagResponse>,
collection?: string,
onExplain?: (data: ExplainData) => void,
): AgentTool {
@ -86,19 +86,27 @@ export function createKnowledgeQueryTool(
async execute(input: string): Promise<string> {
const question = parseQuestion(input);
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
const res = await client.request({ query: question, collection });
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
const request: GraphRagRequest = {
query: question,
...(collection !== undefined ? { collection } : {}),
};
const res = await client.request(request);
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
// Extract explain data if embedded in the response
const rawRes = res as Record<string, unknown>;
if (rawRes.message_type === "explain" && rawRes.explain_triples && onExplain) {
if (
rawRes.message_type === "explain" &&
rawRes.explain_triples !== undefined &&
onExplain !== undefined
) {
onExplain({
explainId: (rawRes.explain_id as string) ?? "",
triples: rawRes.explain_triples as Triple[],
});
}
if (res.error) return `Error: ${res.error.message}`;
if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response;
},
};
@ -108,7 +116,7 @@ export function createKnowledgeQueryTool(
* Search documents for relevant information.
*/
export function createDocumentQueryTool(
client: RequestResponse<DocumentRagRequest, DocumentRagResponse>,
client: FlowRequestor<DocumentRagRequest, DocumentRagResponse>,
collection?: string,
): AgentTool {
return {
@ -124,8 +132,12 @@ export function createDocumentQueryTool(
],
async execute(input: string): Promise<string> {
const question = parseQuestion(input);
const res = await client.request({ query: question, collection });
if (res.error) return `Error: ${res.error.message}`;
const request: DocumentRagRequest = {
query: question,
...(collection !== undefined ? { collection } : {}),
};
const res = await client.request(request);
if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response;
},
};
@ -153,13 +165,20 @@ function parseTriplesInput(input: string): {
return undefined;
};
return {
s: toTerm(parsed.subject ?? parsed.s),
p: toTerm(parsed.predicate ?? parsed.p),
o: toTerm(parsed.object ?? parsed.o),
limit:
typeof parsed.limit === "number" ? parsed.limit : undefined,
};
const result: {
s?: Term;
p?: Term;
o?: Term;
limit?: number;
} = {};
const s = toTerm(parsed.subject ?? parsed.s);
const p = toTerm(parsed.predicate ?? parsed.p);
const o = toTerm(parsed.object ?? parsed.o);
if (s !== undefined) result.s = s;
if (p !== undefined) result.p = p;
if (o !== undefined) result.o = o;
if (typeof parsed.limit === "number") result.limit = parsed.limit;
return result;
} catch {
// If not valid JSON, treat as a subject search
return {
@ -172,7 +191,7 @@ function parseTriplesInput(input: string): {
* Query for specific triples (subject-predicate-object relationships) in the knowledge graph.
*/
export function createTriplesQueryTool(
client: RequestResponse<TriplesQueryRequest, TriplesQueryResponse>,
client: FlowRequestor<TriplesQueryRequest, TriplesQueryResponse>,
collection?: string,
): AgentTool {
return {
@ -199,17 +218,18 @@ export function createTriplesQueryTool(
],
async execute(input: string): Promise<string> {
const { s, p, o, limit } = parseTriplesInput(input);
const res = await client.request({
s,
p,
o,
collection,
const request: TriplesQueryRequest = {
limit: limit ?? 20,
});
...(s !== undefined ? { s } : {}),
...(p !== undefined ? { p } : {}),
...(o !== undefined ? { o } : {}),
...(collection !== undefined ? { collection } : {}),
};
const res = await client.request(request);
if (res.error) return `Error: ${res.error.message}`;
if (res.error !== undefined) return `Error: ${res.error.message}`;
if (!res.triples || res.triples.length === 0) {
if (res.triples === undefined || res.triples.length === 0) {
return "No triples found matching the query.";
}
@ -229,7 +249,7 @@ export function createTriplesQueryTool(
* this function just wraps it as an AgentTool the ReAct agent can invoke.
*/
export function createMcpTool(
client: RequestResponse<ToolRequest, ToolResponse>,
client: FlowRequestor<ToolRequest, ToolResponse>,
toolName: string,
description: string,
args: ToolArg[],
@ -240,9 +260,9 @@ export function createMcpTool(
args,
async execute(input: string): Promise<string> {
const res = await client.request({ name: toolName, parameters: input });
if (res.error) return `Error: ${res.error.message}`;
if (res.text) return res.text;
if (res.object) return res.object;
if (res.error !== undefined) return `Error: ${res.error.message}`;
if (res.text !== undefined) return res.text;
if (res.object !== undefined) return res.object;
return "No content";
},
};

View file

@ -17,7 +17,7 @@ export function filterToolsByGroupAndState(
currentState?: string,
): AgentTool[] {
const groups = requestedGroups ?? ["default"];
const state = currentState || "undefined";
const state = currentState ?? "undefined";
return tools.filter((tool) => isToolAvailable(tool, groups, state));
}
@ -31,12 +31,12 @@ function isToolAvailable(
// Get tool groups (default to ["default"])
let toolGroups = config["group"] as string[] | string | undefined;
if (!toolGroups) toolGroups = ["default"];
if (toolGroups === undefined) toolGroups = ["default"];
if (!Array.isArray(toolGroups)) toolGroups = [toolGroups];
// Get tool applicable states (default to ["*"] = all states)
let applicableStates = config["applicable-states"] as string[] | string | undefined;
if (!applicableStates) applicableStates = ["*"];
if (applicableStates === undefined) applicableStates = ["*"];
if (!Array.isArray(applicableStates)) applicableStates = [applicableStates];
// Group match: wildcard in requested groups, or intersection non-empty
@ -57,5 +57,5 @@ function isToolAvailable(
*/
export function getNextState(tool: AgentTool, currentState: string): string {
const nextState = tool.config?.["state"] as string | undefined;
return nextState || currentState;
return nextState ?? currentState;
}