From 4a8346900d97f16938051f0666e7b1a9214b86c9 Mon Sep 17 00:00:00 2001
From: Huu Le <39040748+leehuwuj@users.noreply.github.com>
Date: Fri, 25 Oct 2024 16:44:56 +0700
Subject: [PATCH] feat: Add multi-agent financial report use case for TS (#394)

---
 .changeset/little-jars-vanish.md              |   5 +
 helpers/typescript.ts                         |  28 ++-
 questions/simple.ts                           |  30 ++--
 .../typescript/blog}/agents.ts                |   0
 .../typescript/blog}/factory.ts               |   0
 .../typescript/blog}/tools.ts                 |   0
 .../typescript/financial_report/agents.ts     |  65 +++++++
 .../typescript/financial_report/factory.ts    | 159 ++++++++++++++++++
 .../typescript/financial_report/tools.ts      |  86 ++++++++++
 .../typescript/workflow/single-agent.ts       |   4 +-
 .../types/streaming/express/package.json      |   3 +-
 templates/types/streaming/nextjs/package.json |   3 +-
 12 files changed, 360 insertions(+), 23 deletions(-)
 create mode 100644 .changeset/little-jars-vanish.md
 rename templates/components/{multiagent/typescript/workflow => agents/typescript/blog}/agents.ts (100%)
 rename templates/components/{multiagent/typescript/workflow => agents/typescript/blog}/factory.ts (100%)
 rename templates/components/{multiagent/typescript/workflow => agents/typescript/blog}/tools.ts (100%)
 create mode 100644 templates/components/agents/typescript/financial_report/agents.ts
 create mode 100644 templates/components/agents/typescript/financial_report/factory.ts
 create mode 100644 templates/components/agents/typescript/financial_report/tools.ts

diff --git a/.changeset/little-jars-vanish.md b/.changeset/little-jars-vanish.md
new file mode 100644
index 00000000..e9fe18ff
--- /dev/null
+++ b/.changeset/little-jars-vanish.md
@@ -0,0 +1,5 @@
+---
+"create-llama": patch
+---
+
+Add multi-agent financial report for Typescript (and update LITS to 0.7.10)
diff --git a/helpers/typescript.ts b/helpers/typescript.ts
index f4d42cf9..da6bd62c 100644
--- a/helpers/typescript.ts
+++ b/helpers/typescript.ts
@@ -1,7 +1,7 @@
 import fs from "fs/promises";
 import os from "os";
 import path from "path";
-import { bold, cyan, yellow } from "picocolors";
+import { bold, cyan, red, yellow } from "picocolors";
 import { assetRelocator, copy } from "../helpers/copy";
 import { callPackageManager } from "../helpers/install";
 import { templatesDir } from "./dir";
@@ -26,6 +26,7 @@ export const installTSTemplate = async ({
   tools,
   dataSources,
   useLlamaParse,
+  agents,
 }: InstallTemplateArgs & { backend: boolean }) => {
   console.log(bold(`Using ${packageManager}.`));
 
@@ -132,6 +133,31 @@ export const installTSTemplate = async ({
       cwd: path.join(multiagentPath, "workflow"),
     });
 
+    // Copy agents use case code for multiagent template
+    if (agents) {
+      console.log("\nCopying agent:", agents, "\n");
+
+      const agentsCodePath = path.join(
+        compPath,
+        "agents",
+        "typescript",
+        agents,
+      );
+
+      await copy("**", path.join(root, relativeEngineDestPath, "workflow"), {
+        parents: true,
+        cwd: agentsCodePath,
+        rename: assetRelocator,
+      });
+    } else {
+      console.log(
+        red(
+          "There is no agent selected for multi-agent template. Please pick an agent to use via --agents flag.",
+        ),
+      );
+      process.exit(1);
+    }
+
     if (framework === "nextjs") {
       // patch route.ts file
       await copy("**", path.join(root, relativeEngineDestPath), {
diff --git a/questions/simple.ts b/questions/simple.ts
index 195e30d8..4888c985 100644
--- a/questions/simple.ts
+++ b/questions/simple.ts
@@ -47,23 +47,19 @@ export const askSimpleQuestions = async (
   let useLlamaCloud = false;
 
   if (appType !== "extractor") {
-    // Default financial report agent use case only supports Python
-    // TODO: Add support for Typescript frameworks
-    if (appType !== "financial_report_agent") {
-      const { language: newLanguage } = await prompts(
-        {
-          type: "select",
-          name: "language",
-          message: "What language do you want to use?",
-          choices: [
-            { title: "Python (FastAPI)", value: "fastapi" },
-            { title: "Typescript (NextJS)", value: "nextjs" },
-          ],
-        },
-        questionHandlers,
-      );
-      language = newLanguage;
-    }
+    const { language: newLanguage } = await prompts(
+      {
+        type: "select",
+        name: "language",
+        message: "What language do you want to use?",
+        choices: [
+          { title: "Python (FastAPI)", value: "fastapi" },
+          { title: "Typescript (NextJS)", value: "nextjs" },
+        ],
+      },
+      questionHandlers,
+    );
+    language = newLanguage;
 
     const { useLlamaCloud: newUseLlamaCloud } = await prompts(
       {
diff --git a/templates/components/multiagent/typescript/workflow/agents.ts b/templates/components/agents/typescript/blog/agents.ts
similarity index 100%
rename from templates/components/multiagent/typescript/workflow/agents.ts
rename to templates/components/agents/typescript/blog/agents.ts
diff --git a/templates/components/multiagent/typescript/workflow/factory.ts b/templates/components/agents/typescript/blog/factory.ts
similarity index 100%
rename from templates/components/multiagent/typescript/workflow/factory.ts
rename to templates/components/agents/typescript/blog/factory.ts
diff --git a/templates/components/multiagent/typescript/workflow/tools.ts b/templates/components/agents/typescript/blog/tools.ts
similarity index 100%
rename from templates/components/multiagent/typescript/workflow/tools.ts
rename to templates/components/agents/typescript/blog/tools.ts
diff --git a/templates/components/agents/typescript/financial_report/agents.ts b/templates/components/agents/typescript/financial_report/agents.ts
new file mode 100644
index 00000000..ca86aa5f
--- /dev/null
+++ b/templates/components/agents/typescript/financial_report/agents.ts
@@ -0,0 +1,65 @@
+import { ChatMessage } from "llamaindex";
+import { FunctionCallingAgent } from "./single-agent";
+import { getQueryEngineTools, lookupTools } from "./tools";
+
+export const createResearcher = async (
+  chatHistory: ChatMessage[],
+  params?: any,
+) => {
+  const queryEngineTools = await getQueryEngineTools(params);
+
+  if (!queryEngineTools) {
+    throw new Error("Query engine tool not found");
+  }
+
+  return new FunctionCallingAgent({
+    name: "researcher",
+    tools: queryEngineTools,
+    systemPrompt: `You are a researcher agent. You are responsible for retrieving information from the corpus.
+## Instructions:
++ Don't synthesize the information, just return the whole retrieved information.
++ Don't need to retrieve the information that is already provided in the chat history and respond with: "There is no new information, please reuse the information from the conversation."
+`,
+    chatHistory,
+  });
+};
+
+export const createAnalyst = async (chatHistory: ChatMessage[]) => {
+  let systemPrompt = `You are an expert in analyzing financial data.
+You are given a task and a set of financial data to analyze. Your task is to analyze the financial data and return a report.
+Your response should include a detailed analysis of the financial data, including any trends, patterns, or insights that you find.
+Construct the analysis in textual format; including tables would be great!
+Don't need to synthesize the data, just analyze and provide your findings.
+Always use the provided information, don't make up any information yourself.`;
+  const tools = await lookupTools(["interpreter"]);
+  if (tools.length > 0) {
+    systemPrompt = `${systemPrompt}
+You are able to visualize the financial data using code interpreter tool.
+It's very useful to create and include visualizations in the report. Never include any code in the report, just the visualization.`;
+  }
+  return new FunctionCallingAgent({
+    name: "analyst",
+    tools: tools,
+    chatHistory,
+  });
+};
+
+export const createReporter = async (chatHistory: ChatMessage[]) => {
+  const tools = await lookupTools(["document_generator"]);
+  let systemPrompt = `You are a report generation assistant tasked with producing a well-formatted report given parsed context.
+Given a comprehensive analysis of the user request, your task is to synthesize the information and return a well-formatted report.
+
+## Instructions
+You are responsible for representing the analysis in a well-formatted report. If tables or visualizations are provided, add them to the most relevant sections.
+Finally, the report should be presented in markdown format.`;
+  if (tools.length > 0) {
+    systemPrompt = `${systemPrompt}. 
+You are also able to generate an HTML file of the report.`;
+  }
+  return new FunctionCallingAgent({
+    name: "reporter",
+    tools: tools,
+    systemPrompt: systemPrompt,
+    chatHistory,
+  });
+};
diff --git a/templates/components/agents/typescript/financial_report/factory.ts b/templates/components/agents/typescript/financial_report/factory.ts
new file mode 100644
index 00000000..ea530969
--- /dev/null
+++ b/templates/components/agents/typescript/financial_report/factory.ts
@@ -0,0 +1,159 @@
+import {
+  Context,
+  StartEvent,
+  StopEvent,
+  Workflow,
+  WorkflowEvent,
+} from "@llamaindex/core/workflow";
+import { Message } from "ai";
+import { ChatMessage, ChatResponseChunk, Settings } from "llamaindex";
+import { getAnnotations } from "../llamaindex/streaming/annotations";
+import { createAnalyst, createReporter, createResearcher } from "./agents";
+import { AgentInput, AgentRunEvent } from "./type";
+
+const TIMEOUT = 360 * 1000;
+const MAX_ATTEMPTS = 2;
+
+class ResearchEvent extends WorkflowEvent<{ input: string }> {}
+class AnalyzeEvent extends WorkflowEvent<{ input: string }> {}
+class ReportEvent extends WorkflowEvent<{ input: string }> {}
+
+const prepareChatHistory = (chatHistory: Message[]): ChatMessage[] => {
+  // By default, the chat history only contains the assistant and user messages
+  // all the agents messages are stored in annotation data which is not visible to the LLM
+
+  const MAX_AGENT_MESSAGES = 10;
+  const agentAnnotations = getAnnotations<{ agent: string; text: string }>(
+    chatHistory,
+    { role: "assistant", type: "agent" },
+  ).slice(-MAX_AGENT_MESSAGES);
+
+  const agentMessages = agentAnnotations
+    .map(
+      (annotation) =>
+        `\n<${annotation.data.agent}>\n${annotation.data.text}\n</${annotation.data.agent}>`,
+    )
+    .join("\n");
+
+  const agentContent = agentMessages
+    ? "Here is the previous conversation of agents:\n" + agentMessages
+    : "";
+
+  if (agentContent) {
+    const agentMessage: ChatMessage = {
+      role: "assistant",
+      content: agentContent,
+    };
+    return [
+      ...chatHistory.slice(0, -1),
+      agentMessage,
+      chatHistory.slice(-1)[0],
+    ] as ChatMessage[];
+  }
+  return chatHistory as ChatMessage[];
+};
+
+export const createWorkflow = (messages: Message[], params?: any) => {
+  const chatHistoryWithAgentMessages = prepareChatHistory(messages);
+  const runAgent = async (
+    context: Context,
+    agent: Workflow,
+    input: AgentInput,
+  ) => {
+    const run = agent.run(new StartEvent({ input }));
+    for await (const event of agent.streamEvents()) {
+      if (event.data instanceof AgentRunEvent) {
+        context.writeEventToStream(event.data);
+      }
+    }
+    return await run;
+  };
+
+  const start = async (context: Context, ev: StartEvent) => {
+    context.set("task", ev.data.input);
+
+    const chatHistoryStr = chatHistoryWithAgentMessages
+      .map((msg) => `${msg.role}: ${msg.content}`)
+      .join("\n");
+
+    // Decision-making process
+    const decision = await decideWorkflow(ev.data.input, chatHistoryStr);
+
+    if (decision !== "publish") {
+      return new ResearchEvent({
+        input: `Research for this task: ${ev.data.input}`,
+      });
+    } else {
+      return new ReportEvent({
+        input: `Publish content based on the chat history\n${chatHistoryStr}\n\n and task: ${ev.data.input}`,
+      });
+    }
+  };
+
+  const decideWorkflow = async (task: string, chatHistoryStr: string) => {
+    const llm = Settings.llm;
+
+    const prompt = `You are an expert in decision-making, helping people write and publish blog posts.
+If the user is asking for a file or to publish content, respond with 'publish'.
+If the user requests to write or update a blog post, respond with 'not_publish'.
+
+Here is the chat history:
+${chatHistoryStr}
+
+The current user request is:
+${task}
+
+Given the chat history and the new user request, decide whether to publish based on existing information.
+Decision (respond with either 'not_publish' or 'publish'):`;
+
+    const output = await llm.complete({ prompt: prompt });
+    const decision = output.text.trim().toLowerCase();
+    return decision === "publish" ? "publish" : "research";
+  };
+
+  const research = async (context: Context, ev: ResearchEvent) => {
+    const researcher = await createResearcher(
+      chatHistoryWithAgentMessages,
+      params,
+    );
+    const researchRes = await runAgent(context, researcher, {
+      message: ev.data.input,
+    });
+    const researchResult = researchRes.data.result;
+    return new AnalyzeEvent({
+      input: `Write a blog post given this task: ${context.get("task")} using this research content: ${researchResult}`,
+    });
+  };
+
+  const analyze = async (context: Context, ev: AnalyzeEvent) => {
+    const analyst = await createAnalyst(chatHistoryWithAgentMessages);
+    const analyzeRes = await runAgent(context, analyst, {
+      message: ev.data.input,
+    });
+    return new ReportEvent({
+      input: `Publish content based on the chat history\n${analyzeRes.data.result}\n\n and task: ${ev.data.input}`,
+    });
+  };
+
+  const report = async (context: Context, ev: ReportEvent) => {
+    const reporter = await createReporter(chatHistoryWithAgentMessages);
+
+    const reportResult = await runAgent(context, reporter, {
+      message: `${ev.data.input}`,
+      streaming: true,
+    });
+    return reportResult as unknown as StopEvent<
+      AsyncGenerator<ChatResponseChunk>
+    >;
+  };
+
+  const workflow = new Workflow({ timeout: TIMEOUT, validate: true });
+  workflow.addStep(StartEvent, start, {
+    outputs: [ResearchEvent, ReportEvent],
+  });
+  workflow.addStep(ResearchEvent, research, { outputs: AnalyzeEvent });
+  workflow.addStep(AnalyzeEvent, analyze, { outputs: ReportEvent });
+  workflow.addStep(ReportEvent, report, { outputs: StopEvent });
+
+  return workflow;
+};
diff --git a/templates/components/agents/typescript/financial_report/tools.ts b/templates/components/agents/typescript/financial_report/tools.ts
new file mode 100644
index 00000000..3d979a77
--- /dev/null
+++ b/templates/components/agents/typescript/financial_report/tools.ts
@@ -0,0 +1,86 @@
+import fs from "fs/promises";
+import { BaseToolWithCall, LlamaCloudIndex, QueryEngineTool } from "llamaindex";
+import path from "path";
+import { getDataSource } from "../engine";
+import { createTools } from "../engine/tools/index";
+
+export const getQueryEngineTools = async (
+  params?: any,
+): Promise<QueryEngineTool[] | null> => {
+  const topK = process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined;
+
+  const index = await getDataSource(params);
+  if (!index) {
+    return null;
+  }
+  // index is LlamaCloudIndex use two query engine tools
+  if (index instanceof LlamaCloudIndex) {
+    return [
+      new QueryEngineTool({
+        queryEngine: index.asQueryEngine({
+          similarityTopK: topK,
+          retrieval_mode: "files_via_content",
+        }),
+        metadata: {
+          name: "document_retriever",
+          description: `Document retriever that retrieves entire documents from the corpus.
+  ONLY use for research questions that may require searching over entire research reports.
+  Will be slower and more expensive than chunk-level retrieval but may be necessary.`,
+        },
+      }),
+      new QueryEngineTool({
+        queryEngine: index.asQueryEngine({
+          similarityTopK: topK,
+          retrieval_mode: "chunks",
+        }),
+        metadata: {
+          name: "chunk_retriever",
+          description: `Retrieves a small set of relevant document chunks from the corpus.
+      Use for research questions that want to look up specific facts from the knowledge corpus,
+      and need entire documents.`,
+        },
+      }),
+    ];
+  } else {
+    return [
+      new QueryEngineTool({
+        queryEngine: (index as any).asQueryEngine({
+          similarityTopK: topK,
+        }),
+        metadata: {
+          name: "retriever",
+          description: `Use this tool to retrieve information about the text corpus from the index.`,
+        },
+      }),
+    ];
+  }
+};
+
+export const getAvailableTools = async () => {
+  const configFile = path.join("config", "tools.json");
+  let toolConfig: any;
+  const tools: BaseToolWithCall[] = [];
+  try {
+    toolConfig = JSON.parse(await fs.readFile(configFile, "utf8"));
+  } catch (e) {
+    console.info(`Could not read ${configFile} file. Using no tools.`);
+  }
+  if (toolConfig) {
+    tools.push(...(await createTools(toolConfig)));
+  }
+  const queryEngineTools = await getQueryEngineTools();
+  if (queryEngineTools) {
+    tools.push(...queryEngineTools);
+  }
+
+  return tools;
+};
+
+export const lookupTools = async (
+  toolNames: string[],
+): Promise<BaseToolWithCall[]> => {
+  const availableTools = await getAvailableTools();
+  return availableTools.filter((tool) =>
+    toolNames.includes(tool.metadata.name),
+  );
+};
diff --git a/templates/components/multiagent/typescript/workflow/single-agent.ts b/templates/components/multiagent/typescript/workflow/single-agent.ts
index 5344f108..c32ad82f 100644
--- a/templates/components/multiagent/typescript/workflow/single-agent.ts
+++ b/templates/components/multiagent/typescript/workflow/single-agent.ts
@@ -182,7 +182,9 @@ export class FunctionCallingAgent extends Workflow {
       // TODO: make logger optional in callTool in framework
       const toolOutput = await callTool(targetTool, call, {
         log: () => {},
-        error: console.error.bind(console),
+        error: (...args: unknown[]) => {
+          console.error(`[Tool ${call.name} Error]:`, ...args);
+        },
         warn: () => {},
       });
       toolMsgs.push({
diff --git a/templates/types/streaming/express/package.json b/templates/types/streaming/express/package.json
index ef5ae0bd..8d59ae7a 100644
--- a/templates/types/streaming/express/package.json
+++ b/templates/types/streaming/express/package.json
@@ -15,13 +15,12 @@
     "dev": "concurrently \"tsup index.ts --format esm --dts --watch\" \"nodemon --watch dist/index.js\""
   },
   "dependencies": {
-    "@llamaindex/core": "^0.2.6",
     "ai": "3.3.42",
     "cors": "^2.8.5",
     "dotenv": "^16.3.1",
     "duck-duck-scrape": "^2.2.5",
     "express": "^4.18.2",
-    "llamaindex": "0.6.22",
+    "llamaindex": "0.7.10",
     "pdf2json": "3.0.5",
     "ajv": "^8.12.0",
     "@e2b/code-interpreter": "0.0.9-beta.3",
diff --git a/templates/types/streaming/nextjs/package.json b/templates/types/streaming/nextjs/package.json
index 4153d741..090cfc31 100644
--- a/templates/types/streaming/nextjs/package.json
+++ b/templates/types/streaming/nextjs/package.json
@@ -12,7 +12,6 @@
   "dependencies": {
     "@apidevtools/swagger-parser": "^10.1.0",
     "@e2b/code-interpreter": "0.0.9-beta.3",
-    "@llamaindex/core": "^0.2.6",
     "@llamaindex/pdf-viewer": "^1.1.3",
     "@radix-ui/react-collapsible": "^1.0.3",
     "@radix-ui/react-hover-card": "^1.0.7",
@@ -27,7 +26,7 @@
     "duck-duck-scrape": "^2.2.5",
     "formdata-node": "^6.0.3",
     "got": "^14.4.1",
-    "llamaindex": "0.6.22",
+    "llamaindex": "0.7.10",
     "lucide-react": "^0.294.0",
     "next": "^14.2.4",
     "react": "^18.2.0",
-- 
GitLab