Advance TS port Effect workbench

This commit is contained in:
elpresidank 2026-06-01 16:22:25 -05:00
parent 92dae8c374
commit 3515106670
116 changed files with 12286 additions and 9584 deletions

View file

@ -1 +1 @@
export { McpToolService } from "./service.js";
export { McpToolService, run } from "./service.js";

View file

@ -17,152 +17,312 @@ import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
makeFlowProcessorProgram,
errorMessage,
type ProcessorConfig,
type FlowContext,
type ToolRequest,
type ToolResponse,
type EffectConfigHandler,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
type Spec,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { Context, Effect, Layer, Ref } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
interface McpServiceConfig {
url: string;
"remote-name"?: string;
"auth-token"?: string;
const McpServiceConfig = S.Struct({
url: S.String,
"remote-name": S.optionalKey(S.String),
"auth-token": S.optionalKey(S.String),
});
type McpServiceConfig = typeof McpServiceConfig.Type;
const decodeRawMcpConfig = S.decodeUnknownOption(S.Record(S.String, S.String));
const decodeMcpServiceConfig = S.decodeUnknownOption(McpServiceConfig.pipe(S.fromJsonString));
const decodeToolParameters = S.decodeUnknownOption(S.Record(S.String, S.Unknown).pipe(S.fromJsonString));
const encodeJson = S.encodeUnknownOption(S.UnknownFromJsonString);
export class McpToolError extends S.TaggedErrorClass<McpToolError>()(
"McpToolError",
{
message: S.String,
operation: S.String,
tool: S.optionalKey(S.String),
},
) {}
export interface McpToolRuntimeService {
readonly configure: (
config: Record<string, unknown>,
version: number,
) => Effect.Effect<void>;
readonly invokeTool: (
name: string,
parameters: Record<string, unknown>,
) => Effect.Effect<string | unknown, McpToolError>;
}
export class McpToolService extends FlowProcessor {
private mcpServices: Record<string, McpServiceConfig> = {};
export class McpToolRuntime extends Context.Service<
McpToolRuntime,
McpToolRuntimeService
>()("@trustgraph/flow/agent/mcp-tool/service/McpToolRuntime") {}
const mcpToolError = (
operation: string,
cause: unknown,
tool?: string,
): McpToolError =>
new McpToolError({
operation,
message: errorMessage(cause),
...(tool === undefined ? {} : { tool }),
});
const closeTransport = (
transport: StreamableHTTPClientTransport,
tool: string,
) =>
Effect.tryPromise({
try: () => transport.close(),
catch: (cause) => mcpToolError("close-transport", cause, tool),
}).pipe(
Effect.catch((error) =>
Effect.logError("[McpToolService] Failed to close MCP transport", {
error: error.message,
tool: error.tool ?? tool,
}),
),
);
const loadMcpServices = Effect.fn("McpToolRuntime.loadMcpServices")(function* (
config: Record<string, unknown>,
version: number,
) {
yield* Effect.log(`[McpToolService] Got config version ${version}`);
if (!("mcp" in config) || typeof config.mcp !== "object" || config.mcp === null) {
return {};
}
const rawConfig = decodeRawMcpConfig(config.mcp);
if (O.isNone(rawConfig)) {
yield* Effect.logError("[McpToolService] MCP config must be an object of JSON strings");
return {};
}
const services: Record<string, McpServiceConfig> = {};
for (const [name, value] of Object.entries(rawConfig.value)) {
const decoded = decodeMcpServiceConfig(value);
if (O.isNone(decoded)) {
yield* Effect.logError(`[McpToolService] Failed to parse MCP config for ${name}`);
continue;
}
services[name] = decoded.value;
yield* Effect.log(`[McpToolService] Registered MCP service: ${name}`);
}
yield* Effect.log(
`[McpToolService] ${Object.keys(services).length} MCP services configured`,
);
return services;
});
const invokeConfiguredTool = Effect.fn("McpToolRuntime.invokeTool")(function* (
services: Record<string, McpServiceConfig>,
name: string,
parameters: Record<string, unknown>,
) {
if (!(name in services)) {
return yield* mcpToolError("lookup-service", `MCP service "${name}" not known`, name);
}
const svcConfig = services[name];
if (svcConfig.url.length === 0) {
return yield* mcpToolError("validate-service", `MCP service "${name}" URL not defined`, name);
}
const remoteName = svcConfig["remote-name"] ?? name;
const headers: Record<string, string> = {};
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
headers.Authorization = `Bearer ${svcConfig["auth-token"]}`;
}
yield* Effect.log(`[McpToolService] Invoking ${remoteName} at ${svcConfig.url}`);
const url = yield* Effect.try({
try: () => new URL(svcConfig.url),
catch: (cause) => mcpToolError("validate-url", cause, name),
});
const transport = new StreamableHTTPClientTransport(
url,
{ requestInit: { headers } },
);
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
const result = yield* Effect.acquireUseRelease(
Effect.tryPromise({
try: async () => {
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
return client;
},
catch: (cause) => mcpToolError("connect", cause, name),
}),
(connectedClient) =>
Effect.tryPromise({
try: () =>
connectedClient.callTool({
name: remoteName,
arguments: parameters,
}),
catch: (cause) => mcpToolError("call-tool", cause, name),
}),
() => closeTransport(transport, name),
);
if (result.structuredContent !== undefined && result.structuredContent !== null) {
return result.structuredContent;
}
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)
.join("");
}
return "No content";
});
export const makeMcpToolRuntime = Effect.gen(function* () {
const servicesRef = yield* Ref.make<Record<string, McpServiceConfig>>({});
return McpToolRuntime.of({
configure: Effect.fn("McpToolRuntime.configure")(function* (config, version) {
const services = yield* loadMcpServices(config, version);
yield* Ref.set(servicesRef, services);
}),
invokeTool: Effect.fn("McpToolRuntime.invokeToolFromRef")(function* (name, parameters) {
const services = yield* Ref.get(servicesRef);
return yield* invokeConfiguredTool(services, name, parameters);
}),
});
});
export const McpToolRuntimeLive = Layer.effect(McpToolRuntime, makeMcpToolRuntime);
const onMcpConfig = Effect.fn("McpToolService.onConfig")(function* (
config: Record<string, unknown>,
version: number,
) {
const runtime = yield* McpToolRuntime;
yield* runtime.configure(config, version);
});
type McpToolHandlerError =
| FlowResourceNotFoundError
| MessagingDeliveryError;
const parametersFromJson = (
name: string,
parameters: string,
): Effect.Effect<Record<string, unknown>, McpToolError> => {
if (parameters.length === 0) return Effect.succeed({});
const decoded = decodeToolParameters(parameters);
if (O.isNone(decoded)) {
return Effect.fail(mcpToolError("decode-parameters", "Tool parameters must be a JSON object", name));
}
return Effect.succeed(decoded.value);
};
const onMcpToolRequest = Effect.fn("McpToolService.onRequest")(function* (
msg: ToolRequest,
properties: Record<string, string>,
flowCtx: FlowContext<McpToolRuntime>,
): Effect.fn.Return<void, McpToolHandlerError, McpToolRuntime> {
const requestId = properties.id;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = yield* flowCtx.flow.producerEffect<ToolResponse>("mcp-tool-response");
const runtime = yield* McpToolRuntime;
const result = yield* parametersFromJson(msg.name, msg.parameters).pipe(
Effect.flatMap((parameters) => runtime.invokeTool(msg.name, parameters)),
Effect.catch((error) =>
Effect.logError(`[McpToolService] Error invoking tool ${msg.name}`, {
error: error.message,
operation: error.operation,
}).pipe(
Effect.flatMap(() =>
responseProducer.send(requestId, {
error: { type: "tool-error", message: error.message },
}),
),
Effect.as(undefined),
),
),
);
if (result === undefined) return;
if (typeof result === "string") {
yield* responseProducer.send(requestId, { text: result });
return;
}
const encoded = encodeJson(result);
yield* responseProducer.send(requestId, {
object: O.isSome(encoded) ? encoded.value : String(result),
});
});
export const makeMcpToolSpecs = (): ReadonlyArray<Spec<McpToolRuntime>> => [
new ConsumerSpec<ToolRequest, McpToolHandlerError, McpToolRuntime>(
"mcp-tool-request",
onMcpToolRequest,
),
new ProducerSpec<ToolResponse>("mcp-tool-response"),
];
export const makeMcpToolConfigHandlers = (): ReadonlyArray<
EffectConfigHandler<never, McpToolRuntime>
> => [onMcpConfig];
export class McpToolService extends FlowProcessor<McpToolRuntime> {
private readonly runtime = Effect.runSync(makeMcpToolRuntime);
constructor(config: ProcessorConfig) {
super(config);
this.registerSpecification(
ConsumerSpec.fromPromise<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
);
this.registerSpecification(new ProducerSpec<ToolResponse>("mcp-tool-response"));
this.registerConfigHandler(this.onMcpConfig.bind(this));
}
private async onMcpConfig(
config: Record<string, unknown>,
version: number,
): Promise<void> {
console.log(`[McpToolService] Got config version ${version}`);
if (!("mcp" in config) || typeof config.mcp !== "object" || config.mcp === null) {
this.mcpServices = {};
return;
for (const spec of makeMcpToolSpecs()) {
this.registerSpecification(spec);
}
const mcpConfig = config.mcp as Record<string, string>;
this.mcpServices = {};
for (const [name, value] of Object.entries(mcpConfig)) {
try {
this.mcpServices[name] = JSON.parse(value) as McpServiceConfig;
console.log(`[McpToolService] Registered MCP service: ${name}`);
} catch (err) {
console.error(`[McpToolService] Failed to parse MCP config for ${name}:`, err);
}
}
console.log(
`[McpToolService] ${Object.keys(this.mcpServices).length} MCP services configured`,
this.registerConfigHandler((config, version) =>
Effect.runPromise(onMcpConfig(config, version).pipe(
Effect.provideService(McpToolRuntime, this.runtime),
)),
);
}
private async onRequest(
msg: ToolRequest,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
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 !== undefined && msg.parameters.length > 0
? JSON.parse(msg.parameters) as Record<string, unknown>
: {},
);
if (typeof result === "string") {
await responseProducer.send(requestId, { text: result });
} else {
await responseProducer.send(requestId, { object: JSON.stringify(result) });
}
} catch (err) {
console.error(`[McpToolService] Error invoking tool ${msg.name}:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
error: { type: "tool-error", message },
});
}
}
private async invokeTool(
name: string,
parameters: Record<string, unknown>,
): Promise<string | unknown> {
if (!(name in this.mcpServices)) {
throw new Error(`MCP service "${name}" not known`);
}
const svcConfig = this.mcpServices[name];
if (svcConfig.url.length === 0) {
throw new Error(`MCP service "${name}" URL not defined`);
}
const remoteName = svcConfig["remote-name"] ?? name;
// Build headers with optional bearer token
const headers: Record<string, string> = {};
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`;
}
console.log(`[McpToolService] Invoking ${remoteName} at ${svcConfig.url}`);
// Connect to streamable HTTP MCP server
const transport = new StreamableHTTPClientTransport(
new URL(svcConfig.url),
{ requestInit: { headers } },
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(McpToolRuntime, this.runtime),
);
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
try {
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
const result = await client.callTool({
name: remoteName,
arguments: parameters,
});
// Extract response — prefer structured content, fall back to text
if (result.structuredContent !== undefined && result.structuredContent !== null) {
return result.structuredContent;
}
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)
.join("");
}
return "No content";
} finally {
await transport.close();
}
}
}
export const program = makeProcessorProgram({
export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolRuntime>({
id: "mcp-tool",
make: (config) => new McpToolService(config),
specs: () => makeMcpToolSpecs(),
configHandlers: () => makeMcpToolConfigHandlers(),
layer: () => McpToolRuntimeLive,
});
export async function run(): Promise<void> {
await Effect.runPromise(program);
}

View file

@ -21,6 +21,8 @@ import {
ConsumerSpec,
ProducerSpec,
RequestResponseSpec,
makeFlowProcessorProgram,
errorMessage,
type ProcessorConfig,
type FlowContext,
type AgentRequest,
@ -35,8 +37,18 @@ import {
type TriplesQueryResponse,
type ToolRequest,
type ToolResponse,
type EffectConfigHandler,
type EffectRequestOptions,
type EffectRequestResponse,
type FlowRequestOptions,
type FlowRequestor,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
type Spec,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import { Context, Effect, Layer, Ref } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import {
createKnowledgeQueryTool,
@ -51,398 +63,490 @@ import type { AgentTool, ToolArg } from "./types.js";
const MAX_ITERATIONS = 10;
export class AgentService extends FlowProcessor {
/** Config-driven tools; null means "use hardcoded fallback". */
private configuredTools: AgentTool[] | null = null;
class AgentToolExecutionError extends S.TaggedErrorClass<AgentToolExecutionError>()(
"AgentToolExecutionError",
{
message: S.String,
},
) {}
const UnknownRecord = S.Record(S.String, S.Unknown);
const ToolArgumentConfig = S.StructWithRest(
S.Struct({
name: S.optionalKey(S.String),
type: S.optionalKey(S.String),
description: S.optionalKey(S.String),
}),
[UnknownRecord],
);
const ToolConfigEntry = S.StructWithRest(
S.Struct({
type: S.optionalKey(S.String),
name: S.optionalKey(S.String),
description: S.optionalKey(S.String),
arguments: ToolArgumentConfig.pipe(S.Array, S.optionalKey),
}),
[UnknownRecord],
);
type ToolConfigEntry = typeof ToolConfigEntry.Type;
const decodeRawToolConfig = S.decodeUnknownOption(S.Record(S.String, S.String));
const decodeToolConfigEntry = S.decodeUnknownOption(ToolConfigEntry.pipe(S.fromJsonString));
export interface AgentRuntimeService {
readonly configureTools: (
config: Record<string, unknown>,
version: number,
) => Effect.Effect<void>;
readonly getConfiguredTools: Effect.Effect<ReadonlyArray<AgentTool> | null>;
}
export class AgentRuntime extends Context.Service<AgentRuntime, AgentRuntimeService>()(
"@trustgraph/flow/agent/react/service/AgentRuntime",
) {}
const toEffectRequestOptions = <TRes>(
options: FlowRequestOptions<TRes> | undefined,
): EffectRequestOptions<TRes> | undefined => {
if (options === undefined) return undefined;
return {
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
...(options.recipient === undefined
? {}
: {
recipient: (response: TRes) =>
Effect.promise(() => options.recipient?.(response) ?? Promise.resolve(true)),
}),
};
};
const toPromiseRequestor = <TReq, TRes>(
requestor: EffectRequestResponse<TReq, TRes>,
): FlowRequestor<TReq, TRes> => ({
request: (request, options) =>
Effect.runPromise(requestor.request(request, toEffectRequestOptions(options))),
stop: () => Effect.runPromise(requestor.stop),
});
const buildConfiguredTool = (
toolId: string,
data: ToolConfigEntry,
): AgentTool | null => {
const implType = data.type ?? "";
const name = data.name ?? "";
const description = data.description ?? "";
const config = { ...data } as Record<string, unknown>;
if (name.length === 0) {
console.warn(`[AgentService] Skipping tool with no name: ${toolId}`);
return null;
}
switch (implType) {
case "knowledge-query":
return {
name,
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,
execute: async () => "",
};
case "document-query":
return {
name,
description:
description.length > 0
? description
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config,
execute: async () => "",
};
case "triples-query":
return {
name,
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)" },
{ name: "object", type: "string", description: "Object entity (optional)" },
],
config,
execute: async () => "",
};
case "mcp-tool": {
const args: ToolArg[] = (data.arguments ?? []).map((arg) => ({
name: arg.name ?? "",
type: arg.type ?? "string",
description: arg.description ?? "",
}));
return {
name,
description,
args,
config,
execute: async () => "",
};
}
default:
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`);
return null;
}
};
const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(function* (
config: Record<string, unknown>,
version: number,
) {
yield* Effect.log(`[AgentService] Loading tool configuration version ${version}`);
if (!("tool" in config) || typeof config.tool !== "object" || config.tool === null) {
yield* Effect.log("[AgentService] No tool config found, using default tools");
return null;
}
const rawConfig = decodeRawToolConfig(config.tool);
if (O.isNone(rawConfig)) {
yield* Effect.logError("[AgentService] Tool config must be an object of JSON strings");
return null;
}
const tools: AgentTool[] = [];
for (const [toolId, toolValue] of Object.entries(rawConfig.value)) {
const decoded = decodeToolConfigEntry(toolValue);
if (O.isNone(decoded)) {
yield* Effect.logError(`[AgentService] Failed to parse tool config ${toolId}`);
continue;
}
const tool = buildConfiguredTool(toolId, decoded.value);
if (tool === null) continue;
tools.push(tool);
yield* Effect.log(`[AgentService] Registered tool: ${tool.name} (${tool.config?.type ?? "unknown"})`);
}
yield* Effect.log(`[AgentService] ${tools.length} tools loaded from config`);
return tools.length > 0 ? tools : null;
});
export const makeAgentRuntime = Effect.gen(function* () {
const configuredToolsRef = yield* Ref.make<ReadonlyArray<AgentTool> | null>(null);
return AgentRuntime.of({
configureTools: Effect.fn("AgentRuntime.configureTools")(function* (config, version) {
const tools = yield* loadConfiguredTools(config, version);
yield* Ref.set(configuredToolsRef, tools);
}),
getConfiguredTools: Ref.get(configuredToolsRef),
});
});
export const AgentRuntimeLive = Layer.effect(AgentRuntime, makeAgentRuntime);
const onToolsConfig = Effect.fn("AgentService.onToolsConfig")(function* (
config: Record<string, unknown>,
version: number,
) {
const runtime = yield* AgentRuntime;
yield* runtime.configureTools(config, version);
});
const wireTools = Effect.fn("AgentService.wireTools")(function* (
tools: ReadonlyArray<AgentTool>,
flowCtx: FlowContext<AgentRuntime>,
collection: string | undefined,
onExplain: (data: ExplainData) => void,
) {
const graphRag = yield* flowCtx.flow.requestorEffect<GraphRagRequest, GraphRagResponse>("graph-rag");
const docRag = yield* flowCtx.flow.requestorEffect<DocumentRagRequest, DocumentRagResponse>("doc-rag");
const triples = yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples");
const mcpTool = yield* flowCtx.flow.requestorEffect<ToolRequest, ToolResponse>("mcp-tool");
return tools.map((tool) => {
const implType = tool.config?.type as string | undefined;
switch (implType) {
case "knowledge-query": {
const live = createKnowledgeQueryTool(
toPromiseRequestor(graphRag),
collection,
onExplain,
);
return { ...tool, execute: live.execute };
}
case "document-query": {
const live = createDocumentQueryTool(
toPromiseRequestor(docRag),
collection,
);
return { ...tool, execute: live.execute };
}
case "triples-query": {
const live = createTriplesQueryTool(
toPromiseRequestor(triples),
collection,
);
return { ...tool, execute: live.execute };
}
case "mcp-tool": {
const live = createMcpTool(
toPromiseRequestor(mcpTool),
tool.name,
tool.description,
tool.args,
);
return { ...tool, execute: live.execute };
}
default:
return tool;
}
});
});
const defaultTools = Effect.fn("AgentService.defaultTools")(function* (
flowCtx: FlowContext<AgentRuntime>,
collection: string | undefined,
onExplain: (data: ExplainData) => void,
) {
const graphRag = yield* flowCtx.flow.requestorEffect<GraphRagRequest, GraphRagResponse>("graph-rag");
const docRag = yield* flowCtx.flow.requestorEffect<DocumentRagRequest, DocumentRagResponse>("doc-rag");
const triples = yield* flowCtx.flow.requestorEffect<TriplesQueryRequest, TriplesQueryResponse>("triples");
return [
createKnowledgeQueryTool(
toPromiseRequestor(graphRag),
collection,
onExplain,
),
createDocumentQueryTool(
toPromiseRequestor(docRag),
collection,
),
createTriplesQueryTool(
toPromiseRequestor(triples),
collection,
),
];
});
const executeTool = (
tool: AgentTool,
input: string,
): Effect.Effect<string> =>
Effect.tryPromise({
try: () => tool.execute(input),
catch: (cause) => new AgentToolExecutionError({ message: errorMessage(cause) }),
}).pipe(
Effect.catch((error: AgentToolExecutionError) =>
Effect.succeed(`Error executing tool: ${error.message}`),
),
);
type AgentHandlerError =
| FlowResourceNotFoundError
| MessagingDeliveryError;
const onAgentRequest = Effect.fn("AgentService.onRequest")(function* (
msg: AgentRequest,
properties: Record<string, string>,
flowCtx: FlowContext<AgentRuntime>,
): Effect.fn.Return<void, AgentHandlerError, AgentRuntime> {
const requestId = properties.id;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = yield* flowCtx.flow.producerEffect<AgentResponse>("agent-response");
yield* Effect.gen(function* () {
const runtime = yield* AgentRuntime;
const explainEvents: ExplainData[] = [];
const onExplain = (data: ExplainData) => {
explainEvents.push(data);
};
const configuredTools = yield* runtime.getConfiguredTools;
let tools = configuredTools !== null
? yield* wireTools(configuredTools, flowCtx, msg.collection, onExplain)
: yield* defaultTools(flowCtx, msg.collection, onExplain);
tools = filterToolsByGroupAndState(tools, msg.group, msg.state);
const { system, prompt: initialPrompt } = buildReActPrompt(
tools,
msg.question,
);
const llmClient = yield* flowCtx.flow.requestorEffect<
TextCompletionRequest,
TextCompletionResponse
>("llm");
let conversation = initialPrompt;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
yield* Effect.log(
`[AgentService] Iteration ${iteration + 1}/${MAX_ITERATIONS} for request ${requestId}`,
);
const llmResponse = yield* llmClient.request({
system,
prompt: conversation,
});
if (llmResponse.error !== undefined) {
yield* responseProducer.send(requestId, {
chunk_type: "error",
content: `LLM error: ${llmResponse.error.message}`,
end_of_dialog: true,
});
return;
}
const text = llmResponse.response;
const parsed = parseReActResponse(text);
if (parsed.thought.length > 0) {
yield* responseProducer.send(requestId, {
chunk_type: "thought",
content: parsed.thought,
end_of_message: true,
});
}
if (parsed.finalAnswer.length > 0) {
for (const explain of explainEvents) {
yield* responseProducer.send(requestId, {
chunk_type: "explain",
content: "",
explain_id: explain.explainId,
explain_triples: explain.triples,
} as AgentResponse);
}
yield* responseProducer.send(requestId, {
chunk_type: "answer",
content: parsed.finalAnswer,
end_of_message: true,
end_of_dialog: true,
});
return;
}
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
const tool = tools.find((candidate) => candidate.name === parsed.action);
const observation = tool === undefined
? `Unknown tool: ${parsed.action}. Available tools: ${tools.map((candidate) => candidate.name).join(", ")}`
: yield* executeTool(tool, parsed.actionInput);
yield* responseProducer.send(requestId, {
chunk_type: "observation",
content: observation,
end_of_message: true,
});
conversation += `\n${text}\nObservation: ${observation}\n`;
} else if (parsed.finalAnswer.length === 0) {
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
}
}
yield* responseProducer.send(requestId, {
chunk_type: "error",
content:
"Maximum reasoning iterations reached without a final answer. " +
"The agent was unable to complete the task within the allowed steps.",
end_of_message: true,
end_of_dialog: true,
});
}).pipe(
Effect.catch((error: unknown) =>
Effect.logError(`[AgentService] Error processing request ${requestId}`, {
error: error instanceof Error ? error.message : String(error),
}).pipe(
Effect.flatMap(() =>
responseProducer.send(requestId, {
chunk_type: "error",
content: `Agent error: ${error instanceof Error ? error.message : String(error)}`,
end_of_message: true,
end_of_dialog: true,
}),
),
),
),
);
});
export const makeAgentSpecs = (): ReadonlyArray<Spec<AgentRuntime>> => [
new ConsumerSpec<AgentRequest, AgentHandlerError, AgentRuntime>(
"agent-request",
onAgentRequest,
),
new ProducerSpec<AgentResponse>("agent-response"),
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm",
"text-completion-request",
"text-completion-response",
),
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
"graph-rag",
"graph-rag-request",
"graph-rag-response",
),
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
"doc-rag",
"document-rag-request",
"document-rag-response",
),
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
"triples",
"triples-request",
"triples-response",
),
new RequestResponseSpec<ToolRequest, ToolResponse>(
"mcp-tool",
"mcp-tool-request",
"mcp-tool-response",
),
];
export const makeAgentConfigHandlers = (): ReadonlyArray<
EffectConfigHandler<never, AgentRuntime>
> => [onToolsConfig];
export class AgentService extends FlowProcessor<AgentRuntime> {
private readonly runtime = Effect.runSync(makeAgentRuntime);
constructor(config: ProcessorConfig) {
super(config);
// Consumer: agent requests
this.registerSpecification(
ConsumerSpec.fromPromise<AgentRequest>("agent-request", this.onRequest.bind(this)),
);
for (const spec of makeAgentSpecs()) {
this.registerSpecification(spec);
}
// Producer: agent responses (streaming chunks)
this.registerSpecification(new ProducerSpec<AgentResponse>("agent-response"));
// Request-response clients for tool execution
this.registerSpecification(
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm",
"text-completion-request",
"text-completion-response",
),
this.registerConfigHandler((config, version) =>
Effect.runPromise(onToolsConfig(config, version).pipe(
Effect.provideService(AgentRuntime, this.runtime),
)),
);
this.registerSpecification(
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
"graph-rag",
"graph-rag-request",
"graph-rag-response",
),
);
this.registerSpecification(
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
"doc-rag",
"document-rag-request",
"document-rag-response",
),
);
this.registerSpecification(
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
"triples",
"triples-request",
"triples-response",
),
);
// MCP tool invocation client
this.registerSpecification(
new RequestResponseSpec<ToolRequest, ToolResponse>(
"mcp-tool",
"mcp-tool-request",
"mcp-tool-response",
),
);
// Register for config-push to build tools dynamically
this.registerConfigHandler(this.onToolsConfig.bind(this));
console.log("[AgentService] Service initialized");
}
// ---------- Config-driven tool registration ----------
private async onToolsConfig(
config: Record<string, unknown>,
version: number,
): Promise<void> {
console.log(`[AgentService] Loading tool configuration version ${version}`);
try {
if (!("tool" in config) || typeof config.tool !== "object" || config.tool === null) {
// No tool config — keep using hardcoded fallback
this.configuredTools = null;
console.log("[AgentService] No tool config found, using default tools");
return;
}
const toolConfig = config.tool as Record<string, string>;
const tools: AgentTool[] = [];
for (const [_toolId, toolValue] of Object.entries(toolConfig)) {
try {
const data = JSON.parse(toolValue) as Record<string, unknown>;
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.length === 0) {
console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`);
continue;
}
let tool: AgentTool | null = null;
switch (implType) {
case "knowledge-query":
// Will be wired to requestor at request time
tool = {
name,
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
};
break;
case "document-query":
tool = {
name,
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 () => "",
};
break;
case "triples-query":
tool = {
name,
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)" },
{ name: "object", type: "string", description: "Object entity (optional)" },
],
config: data,
execute: async () => "",
};
break;
case "mcp-tool": {
const configArgs = (data["arguments"] as Array<Record<string, string>>) ?? [];
const args: ToolArg[] = configArgs.map((a) => ({
name: a.name ?? "",
type: a.type ?? "string",
description: a.description ?? "",
}));
// Create a placeholder — will be wired to the MCP requestor at request time
tool = {
name,
description,
args,
config: data,
execute: async () => "", // placeholder
};
break;
}
default:
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`);
continue;
}
if (tool !== null) {
tools.push(tool);
console.log(`[AgentService] Registered tool: ${name} (${implType})`);
}
} catch (err) {
console.error(`[AgentService] Failed to parse tool config ${_toolId}:`, err);
}
}
this.configuredTools = tools.length > 0 ? tools : null;
console.log(`[AgentService] ${tools.length} tools loaded from config`);
} catch (err) {
console.error("[AgentService] Config reload failed:", err);
}
}
/**
* Wire up tool execute functions with live requestors from the flow context.
* Config-driven tools store placeholders; this replaces them with real impls.
*/
private wireTools(
tools: AgentTool[],
flowCtx: FlowContext,
collection?: string,
onExplain?: (data: ExplainData) => void,
): AgentTool[] {
return tools.map((tool) => {
const implType = tool.config?.["type"] as string | undefined;
switch (implType) {
case "knowledge-query": {
const live = createKnowledgeQueryTool(
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
collection,
onExplain,
);
return { ...tool, execute: live.execute };
}
case "document-query": {
const live = createDocumentQueryTool(
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
collection,
);
return { ...tool, execute: live.execute };
}
case "triples-query": {
const live = createTriplesQueryTool(
flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
collection,
);
return { ...tool, execute: live.execute };
}
case "mcp-tool": {
const live = createMcpTool(
flowCtx.flow.requestor<ToolRequest, ToolResponse>("mcp-tool"),
tool.name,
tool.description,
tool.args,
);
return { ...tool, execute: live.execute };
}
default:
return tool;
}
});
}
private async onRequest(
msg: AgentRequest,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (requestId === undefined || requestId.length === 0) return;
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
try {
// Accumulate explain data from tool calls for emission after completion
const explainEvents: ExplainData[] = [];
const onExplain = (data: ExplainData) => {
explainEvents.push(data);
};
// Build tools — config-driven or hardcoded fallback
let tools: AgentTool[];
if (this.configuredTools !== null) {
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
} else {
// Hardcoded fallback (backward compat)
tools = [
createKnowledgeQueryTool(
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
msg.collection,
onExplain,
),
createDocumentQueryTool(
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
msg.collection,
),
createTriplesQueryTool(
flowCtx.flow.requestor<TriplesQueryRequest, TriplesQueryResponse>("triples"),
msg.collection,
),
];
}
// Apply tool filtering by group and state
tools = filterToolsByGroupAndState(tools, msg.group, msg.state);
// Build the ReAct prompt
const { system, prompt: initialPrompt } = buildReActPrompt(
tools,
msg.question,
);
const llmClient = flowCtx.flow.requestor<
TextCompletionRequest,
TextCompletionResponse
>("llm");
// Conversation accumulates the full exchange for multi-turn reasoning
let conversation = initialPrompt;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
console.log(
`[AgentService] Iteration ${iteration + 1}/${MAX_ITERATIONS} for request ${requestId}`,
);
// Call LLM (non-streaming for MVP)
const llmResponse = await llmClient.request({
system,
prompt: conversation,
});
if (llmResponse.error !== undefined) {
await responseProducer.send(requestId, {
chunk_type: "error",
content: `LLM error: ${llmResponse.error.message}`,
end_of_dialog: true,
});
return;
}
const text = llmResponse.response;
// Parse the LLM response with simple line-based parsing
const parsed = parseReActResponse(text);
// Send thought chunk
if (parsed.thought.length > 0) {
await responseProducer.send(requestId, {
chunk_type: "thought",
content: parsed.thought,
end_of_message: true,
});
}
// If we got a final answer, emit explain events then send the answer
if (parsed.finalAnswer.length > 0) {
// Emit explain events collected from tool calls
for (const explain of explainEvents) {
await responseProducer.send(requestId, {
chunk_type: "explain",
content: "",
explain_id: explain.explainId,
explain_triples: explain.triples,
} as AgentResponse);
}
await responseProducer.send(requestId, {
chunk_type: "answer",
content: parsed.finalAnswer,
end_of_message: true,
end_of_dialog: true,
});
return;
}
// Execute tool if action was specified
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
const tool = tools.find((t) => t.name === parsed.action);
let observation: string;
if (tool !== undefined) {
try {
observation = await tool.execute(parsed.actionInput);
} catch (err) {
observation = `Error executing tool: ${err instanceof Error ? err.message : String(err)}`;
}
} else {
observation = `Unknown tool: ${parsed.action}. Available tools: ${tools.map((t) => t.name).join(", ")}`;
}
// Send observation chunk
await responseProducer.send(requestId, {
chunk_type: "observation",
content: observation,
end_of_message: true,
});
// Append the full exchange to conversation for the next iteration
conversation += `\n${text}\nObservation: ${observation}\n`;
} 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`;
}
}
// Max iterations reached without a final answer
await responseProducer.send(requestId, {
chunk_type: "error",
content:
"Maximum reasoning iterations reached without a final answer. " +
"The agent was unable to complete the task within the allowed steps.",
end_of_message: true,
end_of_dialog: true,
});
} catch (err) {
console.error(`[AgentService] Error processing request ${requestId}:`, err);
await responseProducer.send(requestId, {
chunk_type: "error",
content: `Agent error: ${err instanceof Error ? err.message : String(err)}`,
end_of_message: true,
end_of_dialog: true,
});
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(AgentRuntime, this.runtime),
);
}
}
@ -524,11 +628,13 @@ function parseReActResponse(text: string): {
};
}
export const program = makeProcessorProgram({
export const program = makeFlowProcessorProgram<ProcessorConfig, never, AgentRuntime>({
id: "agent",
make: (config) => new AgentService(config),
specs: () => makeAgentSpecs(),
configHandlers: () => makeAgentConfigHandlers(),
layer: () => AgentRuntimeLive,
});
export async function run(): Promise<void> {
await AgentService.launch("agent");
await Effect.runPromise(program);
}