mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-03 15:01:00 +02:00
Advance TS port Effect workbench
This commit is contained in:
parent
92dae8c374
commit
3515106670
116 changed files with 12286 additions and 9584 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue