diff --git a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md index f05db05e..3fb9cde4 100644 --- a/ts/EFFECT_NATIVE_REWRITE_AUDIT.md +++ b/ts/EFFECT_NATIVE_REWRITE_AUDIT.md @@ -481,6 +481,24 @@ Notes: - `bun run --cwd ts/packages/flow test` - `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 - Status: migrated, root-verified, committed, and pushed. diff --git a/ts/packages/flow/src/__tests__/agent-service.test.ts b/ts/packages/flow/src/__tests__/agent-service.test.ts new file mode 100644 index 00000000..8bde685c --- /dev/null +++ b/ts/packages/flow/src/__tests__/agent-service.test.ts @@ -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 => + 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", + }); + }); +}); diff --git a/ts/packages/flow/src/agent/react/service.ts b/ts/packages/flow/src/agent/react/service.ts index efdb94c6..8dcac144 100644 --- a/ts/packages/flow/src/agent/react/service.ts +++ b/ts/packages/flow/src/agent/react/service.ts @@ -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 => - Effect.gen(function* () { - const implType = data.type ?? ""; - const name = data.name ?? ""; - const description = data.description ?? ""; - const config: Record = { ...data }; +) { + const implType = data.type ?? ""; + const name = data.name ?? ""; + const description = data.description ?? ""; + const config: Record = { ...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, @@ -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, + ); } }