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

@ -481,6 +481,24 @@ Notes:
- `bun run --cwd ts/packages/flow test` - `bun run --cwd ts/packages/flow test`
- `cd ts && bun run check:tsgo` - `cd ts && bun run check:tsgo`
### 2026-06-04: Agent Service Match Slice
- Status: migrated and package-verified.
- Completed:
- `ts/packages/flow/src/agent/react/service.ts` no longer uses native
`switch` for configured tool construction, live tool wiring, or ReAct
continuation parsing.
- Configured tool construction now uses `Effect.fn` plus `effect/Match` and
preserves unknown-tool logging/fallback behavior.
- The ReAct parser is exported for focused tests and uses an exhaustive
`Match` over continuation sections.
- Agent service tests cover configured-tool loading, unknown configured-tool
fallback, default descriptions, MCP tool args, continuation parsing, and
final-answer parsing.
- Verification:
- `bun run --cwd ts/packages/flow test -- src/__tests__/agent-service.test.ts`
- `cd ts && bun run check:tsgo`
### 2026-06-02: RAG And Agent Requestor Bridge Slice ### 2026-06-02: RAG And Agent Requestor Bridge Slice
- Status: migrated, root-verified, committed, and pushed. - Status: migrated, root-verified, committed, and pushed.

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