Use Match for agent service dispatch

This commit is contained in:
elpresidank 2026-06-04 05:53:58 -05:00
parent 21620cbf8d
commit 89f9d63b88
3 changed files with 221 additions and 90 deletions

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { makeAgentRuntime, parseReActResponse } from "../agent/react/service.js";
const toolEntry = (entry: Record<string, unknown>): string =>
JSON.stringify(entry);
describe("AgentService helpers", () => {
it.effect(
"loads configured tools through the Match-backed builder",
Effect.fnUntraced(function* () {
const runtime = yield* makeAgentRuntime;
yield* runtime.configureTools({
tool: {
knowledge: toolEntry({
type: "knowledge-query",
name: "Knowledge",
}),
document: toolEntry({
type: "document-query",
name: "Document",
description: "Find document context.",
}),
triples: toolEntry({
type: "triples-query",
name: "Triples",
}),
mcp: toolEntry({
type: "mcp-tool",
name: "Lookup",
description: "Call an external lookup tool.",
arguments: [
{
name: "query",
type: "string",
description: "Lookup query.",
},
],
}),
unknown: toolEntry({
type: "unknown-tool",
name: "Ignored",
}),
},
}, 1);
const tools = yield* runtime.getConfiguredTools;
expect(tools?.map((tool) => tool.name)).toEqual([
"Knowledge",
"Document",
"Triples",
"Lookup",
]);
expect(tools?.map((tool) => tool.config?.type)).toEqual([
"knowledge-query",
"document-query",
"triples-query",
"mcp-tool",
]);
expect(tools?.[0]?.description).toBe(
"Query the knowledge graph for information about entities and their relationships.",
);
expect(tools?.[1]?.description).toBe("Find document context.");
expect(tools?.[3]?.args).toEqual([
{
name: "query",
type: "string",
description: "Lookup query.",
},
]);
}),
);
it("parses ReAct continuation sections through the Match-backed parser", () => {
const parsed = parseReActResponse([
"Thought: first idea",
"continued idea",
"Action: Search",
"extra action words",
"Action Input: {\"question\":\"hello\"}",
"continued input",
].join("\n"));
expect(parsed).toEqual({
thought: "first idea\ncontinued idea",
action: "Search extra action words",
actionInput: "{\"question\":\"hello\"}\ncontinued input",
finalAnswer: "",
});
});
it("parses final answers and ignores later text", () => {
const parsed = parseReActResponse([
"Thought: done",
"Final Answer: answer one",
"answer two",
"Action: ignored",
].join("\n"));
expect(parsed).toEqual({
thought: "done",
action: "",
actionInput: "",
finalAnswer: "answer one\nanswer two\nAction: ignored",
});
});
});

View file

@ -46,7 +46,7 @@ import {
type MessagingDeliveryError,
type Spec,
} from "@trustgraph/base";
import {Context, Effect, Layer, ManagedRuntime, Ref} from "effect";
import {Context, Effect, Layer, ManagedRuntime, Match, Ref} from "effect";
import * as O from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as S from "effect/Schema";
@ -133,83 +133,87 @@ export class AgentRuntime extends Context.Service<AgentRuntime, AgentRuntimeServ
"@trustgraph/flow/agent/react/service/AgentRuntime",
) {}
const buildConfiguredTool = (
const buildConfiguredTool = Effect.fn("AgentService.buildConfiguredTool")(function* (
toolId: string,
data: ToolConfigEntry,
): Effect.Effect<AgentTool | null> =>
Effect.gen(function* () {
const implType = data.type ?? "";
const name = data.name ?? "";
const description = data.description ?? "";
const config: Record<string, unknown> = { ...data };
) {
const implType = data.type ?? "";
const name = data.name ?? "";
const description = data.description ?? "";
const config: Record<string, unknown> = { ...data };
if (name.length === 0) {
yield* Effect.logWarning(`[AgentService] Skipping tool with no name: ${toolId}`);
return null;
}
if (name.length === 0) {
yield* Effect.logWarning(`[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: () => Promise.resolve(""),
};
return yield* Match.value(implType).pipe(
Match.when("knowledge-query", () =>
Effect.succeed({
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: () => Promise.resolve(""),
})
),
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: () => Promise.resolve(""),
};
Match.when("document-query", () =>
Effect.succeed({
name,
description:
description.length > 0
? description
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config,
execute: () => Promise.resolve(""),
})
),
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: () => Promise.resolve(""),
};
Match.when("triples-query", () =>
Effect.succeed({
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: () => Promise.resolve(""),
})
),
case "mcp-tool": {
const args: ToolArg[] = (data.arguments ?? []).map((arg) => ({
name: arg.name ?? "",
type: arg.type ?? "string",
description: arg.description ?? "",
}));
Match.when("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: () => Promise.resolve(""),
};
}
return Effect.succeed({
name,
description,
args,
config,
execute: () => Promise.resolve(""),
});
}),
default:
yield* Effect.logWarning(`[AgentService] Unknown tool type "${implType}" for ${name}`);
return null;
}
});
Match.orElse(() =>
Effect.logWarning(`[AgentService] Unknown tool type "${implType}" for ${name}`).pipe(
Effect.as(null),
)
),
);
});
const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(function* (
config: Record<string, unknown>,
@ -284,30 +288,30 @@ const wireTools = Effect.fn("AgentService.wireTools")(function* (
const rawImplType = tool.config?.type;
const implType = Predicate.isString(rawImplType) ? rawImplType : undefined;
switch (implType) {
case "knowledge-query": {
return Match.value(implType).pipe(
Match.when("knowledge-query", () => {
const live = createKnowledgeQueryTool(
graphRag,
collection,
onExplain,
);
return { ...tool, execute: live.execute };
}
case "document-query": {
}),
Match.when("document-query", () => {
const live = createDocumentQueryTool(
docRag,
collection,
);
return { ...tool, execute: live.execute };
}
case "triples-query": {
}),
Match.when("triples-query", () => {
const live = createTriplesQueryTool(
triples,
collection,
);
return { ...tool, execute: live.execute };
}
case "mcp-tool": {
}),
Match.when("mcp-tool", () => {
const live = createMcpTool(
mcpTool,
tool.name,
@ -315,10 +319,9 @@ const wireTools = Effect.fn("AgentService.wireTools")(function* (
tool.args,
);
return { ...tool, execute: live.execute };
}
default:
return tool;
}
}),
Match.orElse(() => tool),
);
});
});
@ -534,7 +537,7 @@ export const AgentService = makeAgentService;
* For the MVP this avoids the complexity of the streaming parser --
* we parse the complete response at once.
*/
function parseReActResponse(text: string): {
export function parseReActResponse(text: string): {
thought: string;
action: string;
actionInput: string;
@ -582,18 +585,19 @@ function parseReActResponse(text: string): {
currentSection = null;
} else if (trimmed.length > 0 && currentSection !== null) {
// Continuation line for current section
switch (currentSection) {
case "thought":
Match.value(currentSection).pipe(
Match.when("thought", () => {
thought += "\n" + trimmed;
break;
case "action":
}),
Match.when("action", () => {
// Action should be a single line (tool name), but handle multi-line
action += " " + trimmed;
break;
case "action_input":
}),
Match.when("action_input", () => {
actionInput += "\n" + trimmed;
break;
}
}),
Match.exhaustive,
);
}
}