diff --git a/packages/agents/src/Agent.ts b/packages/agents/src/Agent.ts deleted file mode 100644 index 5ef940e..0000000 --- a/packages/agents/src/Agent.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { JSONSchema, Schema } from "@effect/schema"; -import { LLMService } from "./services"; -import { Effect, Match, pipe } from "effect"; -import { extractJSON, getMdast, stringifyMdast } from "./md"; -import { get, isArray, unique } from "radash"; -import { isSchema } from "@effect/schema/Schema"; -import { getSectionByHeading } from "./md/getSection"; -import { isRight } from "effect/Either"; - -export class Task { - constructor( - readonly instructions: string, - readonly acceptanceCriteria: Array = [], - readonly format: Schema.Schema.AnyNoContext, - ) {} -} - -class TaskResult { - constructor( - readonly data: Schema.Schema.Type, - readonly unstructured: string, - ) {} -} - -type SupportedFormats = string | string[] | Schema.Schema.AnyNoContext; - -const formatString = Match.type().pipe( - Match.when(Match.string, (_) => _), - Match.when(isArray, (_) => _.map((_) => `- ${_}`).join("\n")), - Match.when(isSchema, (_) => JSON.stringify(JSONSchema.make(_))), - Match.when(Match.record, (_) => JSON.stringify(_)), - Match.exhaustive, -); - -export const InjectSection = (section: string, data: SupportedFormats) => { - const content = formatString(data); - return [`## ${section}`, content].join("\n"); -}; - -type Section = { - heading: string; - purpose: string; - format: string; -}; - -const sections = { - planning: { - heading: "Planning", - purpose: - "Use this section to plan your response to the task step by step, and reflect on your approach. It will be discarded. ", - format: "valid markdown", - }, - data: { - heading: "Structured Data", - purpose: "Structured data conforming to the provided schema (JSON Schema)", - format: "A json code block containing the valid JSON ", - }, - unstructured: { - heading: "Unstructured Data", - purpose: - "Any additional data requested by the instructions which was not part of the structured data. This should rarely be used.", - format: "valid markdown", - }, -}; - -const toMarkdownTable = >(data: Array) => { - const keys = unique(data.flatMap((item) => Object.keys(item))); - const headingRow = `| ${keys.join(" | ")} |`; - const separatorRow = `|${keys.map(() => "-----").join("|")}|`; - const dataRows = data - .map((item) => `| ${keys.map((key) => get(item, key, "")).join(" | ")} |`) - .join("\n"); - return [headingRow, separatorRow, dataRows].join("\n"); -}; - -const promptContext = [ - "Your responses will only be seen by other Agents - so be precise, follow instructions, and format your response carefully.", - "Your response should be in markdown organized into sections by blocks which start with specific Heading Level 2 headings. For example, \n```md\n## MySection \n\n This is my section. \n```\n would be a MySection section with contents 'This is my section' ", - "The table below lists the valid sections, what heading to use, when to use it (purpose), and how to format their contents. ", - toMarkdownTable(Object.values(sections)), -]; - -export abstract class Agent { - abstract name: string; - abstract description: string; - - constructor() {} - - generatePrompt(task: Task) { - const about = InjectSection( - "About You", - [ - `You are a ${this.name}, responsible for ${this.description}.`, - "You are part of a multi-agent system which helps humans solve complex problems.", - ].join("\n"), - ); - - const context = InjectSection( - "Response Conventions", - promptContext.join("\n"), - ); - - const details = [ - InjectSection("Instructions", task.instructions), - InjectSection("Acceptance Criteria", task.acceptanceCriteria), - InjectSection("Schema (JsonSchema)", task.format), - ]; - - return [about, context, ...details].join("\n\n"); - } - - process(task: Effect.Effect, Error, never>) { - const generatePrompt = this.generatePrompt.bind(this); - return Effect.gen(function* () { - const llm = yield* LLMService; - - const taskReal = yield* task; - const prompt = generatePrompt(taskReal); - const response = yield* llm.generateText(prompt, "mistral-small"); - - const mdast = yield* getMdast(response); - const data = Schema.decodeEither(taskReal.format)( - extractJSON(getSectionByHeading(mdast, sections.data.heading)), - ); - if (isRight(data)) { - return new TaskResult(data.right, ""); - } - return new TaskResult(null, "null"); - }); - } -} - -export class HelloAgent extends Agent { - name = "Hello Agent"; - description = "Welcomes people to the project"; -} - diff --git a/packages/agents/src/Agent.spec.ts b/packages/agents/src/Agent/Agent.spec.ts similarity index 86% rename from packages/agents/src/Agent.spec.ts rename to packages/agents/src/Agent/Agent.spec.ts index 6032c70..3fb015b 100644 --- a/packages/agents/src/Agent.spec.ts +++ b/packages/agents/src/Agent/Agent.spec.ts @@ -1,7 +1,8 @@ -import { HelloAgent, Task } from "./Agent"; +import { HelloAgent } from "../agents/HelloAgent"; import { Effect } from "effect"; import { Schema } from "@effect/schema"; -import { AppLayer } from "./services"; +import { AppLayer } from "../services"; +import { Task } from "../task"; describe("Agent", () => { it("should ", { timeout: 100000 }, async () => { @@ -18,7 +19,6 @@ describe("Agent", () => { return yield* agent.process(createTask); }); - // const ollamaLayer = LLMServiceLive(); const runnable = Effect.provide(program, AppLayer); const output = await Effect.runPromise(runnable); diff --git a/packages/agents/src/Agent/Agent.ts b/packages/agents/src/Agent/Agent.ts new file mode 100644 index 0000000..29d1980 --- /dev/null +++ b/packages/agents/src/Agent/Agent.ts @@ -0,0 +1,56 @@ +import { Schema } from "@effect/schema"; +import { LLMService } from "../services"; +import { Effect } from "effect"; +import { extractJSON, getMdast } from "../md"; +import { getSectionByHeading } from "../md/getSection"; +import { isRight } from "effect/Either"; +import { Task, TaskResult } from "../task"; +import { Prompts } from "./prompts"; +import { InjectSection } from "./injectSection"; + +export abstract class Agent { + abstract name: string; + abstract description: string; + + constructor() {} + + generatePrompt(task: Task) { + const about = InjectSection( + "About You", + [ + `You are a ${this.name}, responsible for ${this.description}.`, + "You are part of a multi-agent system which helps humans solve complex problems.", + ].join("\n"), + ); + + const details = [ + about, + Prompts.responseConventions, + InjectSection("Instructions", task.instructions), + InjectSection("Acceptance Criteria", task.acceptanceCriteria), + InjectSection("Schema (JsonSchema)", task.format), + ]; + + return details.join("\n\n"); + } + + process(task: Effect.Effect, Error, never>) { + const generatePrompt = this.generatePrompt.bind(this); + return Effect.gen(function* () { + const llm = yield* LLMService; + + const taskReal = yield* task; + const prompt = generatePrompt(taskReal); + const response = yield* llm.generateText(prompt, "mistral-small"); + + const mdast = yield* getMdast(response); + const data = Schema.decodeEither(taskReal.format)( + extractJSON(getSectionByHeading(mdast, Prompts.sectionKeys.data)), + ); + if (isRight(data)) { + return new TaskResult(data.right, ""); + } + return new TaskResult(null, "null"); + }); + } +} diff --git a/packages/agents/src/Agent/injectSection.ts b/packages/agents/src/Agent/injectSection.ts new file mode 100644 index 0000000..986178b --- /dev/null +++ b/packages/agents/src/Agent/injectSection.ts @@ -0,0 +1,19 @@ +import { JSONSchema, Schema } from "@effect/schema"; +import { Match } from "effect"; +import { isArray } from "radash"; +import { isSchema } from "@effect/schema/Schema"; + +type SupportedFormats = string | string[] | Schema.Schema.AnyNoContext; + +const formatString = Match.type().pipe( + Match.when(Match.string, (_) => _), + Match.when(isArray, (_) => _.map((_) => `- ${_}`).join("\n")), + Match.when(isSchema, (_) => JSON.stringify(JSONSchema.make(_))), + Match.when(Match.record, (_) => JSON.stringify(_)), + Match.exhaustive, +); + +export const InjectSection = (section: string, data: SupportedFormats) => { + const content = formatString(data); + return [`## ${section}`, content].join("\n"); +}; \ No newline at end of file diff --git a/packages/agents/src/Agent/prompts.ts b/packages/agents/src/Agent/prompts.ts new file mode 100644 index 0000000..33aaabf --- /dev/null +++ b/packages/agents/src/Agent/prompts.ts @@ -0,0 +1,44 @@ +import { toMarkdownTable } from "../md/utils"; +import { InjectSection } from "./injectSection"; +import { mapValues } from "radash"; + +type Section = { + heading: string; + purpose: string; + format: string; +}; + +const sections = { + planning: { + heading: "Planning", + purpose: + "Use this section to plan your response to the task step by step, and reflect on your approach. It will be discarded. ", + format: "valid markdown", + }, + data: { + heading: "Structured Data", + purpose: "Structured data conforming to the provided schema (JSON Schema)", + format: "A json code block containing the valid JSON ", + }, + unstructured: { + heading: "Unstructured Data", + purpose: + "Any additional data requested by the instructions which was not part of the structured data. This should rarely be used.", + format: "valid markdown", + }, +} satisfies Record; + +export const promptContext = [ + "Your responses will only be seen by other Agents - so be precise, follow instructions, and format your response carefully.", + "Your response should be in markdown organized into sections by blocks which start with specific Heading Level 2 headings. For example, \n```md\n## MySection \n\n This is my section. \n```\n would be a MySection section with contents 'This is my section' ", + "The table below lists the valid sections, what heading to use, when to use it (purpose), and how to format their contents. ", + toMarkdownTable(Object.values(sections)), +]; + +export const Prompts = { + sectionKeys: mapValues(sections, section=>section.heading), + responseConventions: InjectSection( + "Response Conventions", + promptContext.join("\n"), + ), +}; diff --git a/packages/agents/src/agents/HelloAgent.ts b/packages/agents/src/agents/HelloAgent.ts new file mode 100644 index 0000000..86bba01 --- /dev/null +++ b/packages/agents/src/agents/HelloAgent.ts @@ -0,0 +1,6 @@ +import { Agent } from "../Agent/Agent"; + +export class HelloAgent extends Agent { + name = "Hello Agent"; + description = "Welcomes people to the project"; +} \ No newline at end of file diff --git a/packages/agents/src/agents/WikiAgent.ts b/packages/agents/src/agents/WikiAgent.ts new file mode 100644 index 0000000..df77f1a --- /dev/null +++ b/packages/agents/src/agents/WikiAgent.ts @@ -0,0 +1,6 @@ +import { Agent } from "../Agent/Agent"; + +export class WikiAgent extends Agent { + name = "Wikipedia Agent"; + description = "Searches wikipedia for background on a given topic or term." +} \ No newline at end of file diff --git a/packages/agents/src/md/utils.ts b/packages/agents/src/md/utils.ts new file mode 100644 index 0000000..196fa04 --- /dev/null +++ b/packages/agents/src/md/utils.ts @@ -0,0 +1,13 @@ +import { get, unique } from "radash"; + +export const toMarkdownTable = >( + data: Array +) => { + const keys = unique(data.flatMap((item) => Object.keys(item))); + const headingRow = `| ${keys.join(" | ")} |`; + const separatorRow = `|${keys.map(() => "-----").join("|")}|`; + const dataRows = data + .map((item) => `| ${keys.map((key) => get(item, key, "")).join(" | ")} |`) + .join("\n"); + return [headingRow, separatorRow, dataRows].join("\n"); +}; \ No newline at end of file diff --git a/packages/agents/src/services.ts b/packages/agents/src/services.ts index 9d42669..c334577 100644 --- a/packages/agents/src/services.ts +++ b/packages/agents/src/services.ts @@ -1,7 +1,7 @@ // services.ts import { Effect, Context, Layer } from "effect"; -import { Ollama } from "ollama"; +import { ChatRequest, ChatResponse, Message, Ollama } from "ollama"; /** * LLMService tag @@ -9,6 +9,9 @@ import { Ollama } from "ollama"; export class LLMService extends Context.Tag("LLMService")< LLMService, { + readonly chat: ( + req: ChatRequest, + ) => Effect.Effect; readonly generateText: ( prompt: string, model: string, @@ -28,6 +31,11 @@ export const LLMServiceLive = () => { return Layer.succeed( LLMService, LLMService.of({ + chat: (request: ChatRequest) => + Effect.tryPromise({ + try: () => ollama.chat({ ...request, stream: false }), + catch: (err) => new Error('unknown'), + }), generateText: (prompt: string, model: string) => Effect.tryPromise({ try: () => diff --git a/packages/agents/src/task/index.ts b/packages/agents/src/task/index.ts new file mode 100644 index 0000000..aed1a67 --- /dev/null +++ b/packages/agents/src/task/index.ts @@ -0,0 +1,16 @@ +import { Schema } from "@effect/schema"; + +export class Task { + constructor( + readonly instructions: string, + readonly acceptanceCriteria: Array = [], + readonly format: Schema.Schema.AnyNoContext, + ) {} +} + +export class TaskResult { + constructor( + readonly data: Schema.Schema.Type, + readonly unstructured: string, + ) {} +} \ No newline at end of file