From 04ddebcd64c8318247d643c06c487f4a56e0f34e Mon Sep 17 00:00:00 2001 From: Huu Le <39040748+leehuwuj@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:44:33 +0700 Subject: [PATCH] feat: Add publisher agent, merge code with streaming template (#324) --------- Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de> --- .changeset/flat-singers-share.md | 5 + .changeset/gorgeous-penguins-shout.md | 5 + e2e/python/resolve_dependencies.spec.ts | 1 + e2e/utils.ts | 4 +- helpers/env-variables.ts | 39 +-- helpers/python.ts | 40 ++- helpers/tools.ts | 23 ++ helpers/typescript.ts | 5 +- questions.ts | 16 +- .../components/engines/python/agent/engine.py | 2 +- .../engines/python/agent/tools/__init__.py | 27 +- .../python/agent/tools/document_generator.py | 229 ++++++++++++++++ .../engines/python/agent/tools/duckduckgo.py | 34 ++- .../engines/python/agent/tools/img_gen.py | 9 +- .../engines/python/agent/tools/interpreter.py | 14 +- .../components/engines/python/chat/engine.py | 2 +- .../agent/tools/document-generator.ts | 142 ++++++++++ .../typescript/agent/tools/duckduckgo.ts | 29 +- .../engines/typescript/agent/tools/img-gen.ts | 2 +- .../engines/typescript/agent/tools/index.ts | 7 + .../typescript/agent/tools/interpreter.ts | 2 +- .../multiagent/python}/README-template.md | 0 .../multiagent/python}/app/agents/multi.py | 11 +- .../multiagent/python}/app/agents/planner.py | 57 ++-- .../multiagent/python}/app/agents/single.py | 8 +- .../multiagent/python/app/api/routers/chat.py | 46 ++++ .../app/api/routers/vercel_response.py | 94 ++++--- .../multiagent/python/app/engine/engine.py} | 14 +- .../python/app/examples/choreography.py | 32 +++ .../python/app/examples/orchestrator.py | 40 +++ .../python/app/examples/publisher.py | 35 +++ .../python/app/examples/researcher.py | 82 ++++++ .../python/app/examples/workflow.py | 250 ++++++++++++++++++ .../multiagent/typescript/workflow/agents.ts | 101 +++++-- .../multiagent/typescript/workflow/factory.ts | 80 +++++- .../multiagent/typescript/workflow/tools.ts | 52 ++++ .../multiagent/fastapi/app/api/__init__.py | 0 .../fastapi/app/api/routers/__init__.py | 0 .../fastapi/app/api/routers/chat.py | 39 --- .../fastapi/app/api/routers/chat_config.py | 48 ---- .../fastapi/app/api/routers/models.py | 227 ---------------- .../fastapi/app/api/routers/upload.py | 29 -- .../types/multiagent/fastapi/app/config.py | 1 - .../fastapi/app/examples/choreography.py | 25 -- .../fastapi/app/examples/orchestrator.py | 27 -- .../fastapi/app/examples/researcher.py | 39 --- .../fastapi/app/examples/workflow.py | 139 ---------- .../multiagent/fastapi/app/observability.py | 2 - .../types/multiagent/fastapi/app/utils.py | 8 - templates/types/multiagent/fastapi/gitignore | 4 - templates/types/multiagent/fastapi/main.py | 72 ----- .../types/multiagent/fastapi/pyproject.toml | 27 -- .../types/streaming/express/package.json | 3 +- .../streaming/fastapi/app/api/routers/chat.py | 14 +- .../fastapi/app/api/routers/models.py | 45 +++- .../chat/chat-message/chat-agent-events.tsx | 1 + templates/types/streaming/nextjs/package.json | 3 +- 57 files changed, 1408 insertions(+), 884 deletions(-) create mode 100644 .changeset/flat-singers-share.md create mode 100644 .changeset/gorgeous-penguins-shout.md create mode 100644 templates/components/engines/python/agent/tools/document_generator.py create mode 100644 templates/components/engines/typescript/agent/tools/document-generator.ts rename templates/{types/multiagent/fastapi => components/multiagent/python}/README-template.md (100%) rename templates/{types/multiagent/fastapi => components/multiagent/python}/app/agents/multi.py (89%) rename templates/{types/multiagent/fastapi => components/multiagent/python}/app/agents/planner.py (86%) rename templates/{types/multiagent/fastapi => components/multiagent/python}/app/agents/single.py (97%) create mode 100644 templates/components/multiagent/python/app/api/routers/chat.py rename templates/{types/multiagent/fastapi => components/multiagent/python}/app/api/routers/vercel_response.py (66%) rename templates/{types/multiagent/fastapi/app/examples/factory.py => components/multiagent/python/app/engine/engine.py} (81%) create mode 100644 templates/components/multiagent/python/app/examples/choreography.py create mode 100644 templates/components/multiagent/python/app/examples/orchestrator.py create mode 100644 templates/components/multiagent/python/app/examples/publisher.py create mode 100644 templates/components/multiagent/python/app/examples/researcher.py create mode 100644 templates/components/multiagent/python/app/examples/workflow.py create mode 100644 templates/components/multiagent/typescript/workflow/tools.ts delete mode 100644 templates/types/multiagent/fastapi/app/api/__init__.py delete mode 100644 templates/types/multiagent/fastapi/app/api/routers/__init__.py delete mode 100644 templates/types/multiagent/fastapi/app/api/routers/chat.py delete mode 100644 templates/types/multiagent/fastapi/app/api/routers/chat_config.py delete mode 100644 templates/types/multiagent/fastapi/app/api/routers/models.py delete mode 100644 templates/types/multiagent/fastapi/app/api/routers/upload.py delete mode 100644 templates/types/multiagent/fastapi/app/config.py delete mode 100644 templates/types/multiagent/fastapi/app/examples/choreography.py delete mode 100644 templates/types/multiagent/fastapi/app/examples/orchestrator.py delete mode 100644 templates/types/multiagent/fastapi/app/examples/researcher.py delete mode 100644 templates/types/multiagent/fastapi/app/examples/workflow.py delete mode 100644 templates/types/multiagent/fastapi/app/observability.py delete mode 100644 templates/types/multiagent/fastapi/app/utils.py delete mode 100644 templates/types/multiagent/fastapi/gitignore delete mode 100644 templates/types/multiagent/fastapi/main.py delete mode 100644 templates/types/multiagent/fastapi/pyproject.toml diff --git a/.changeset/flat-singers-share.md b/.changeset/flat-singers-share.md new file mode 100644 index 00000000..fce3d6e9 --- /dev/null +++ b/.changeset/flat-singers-share.md @@ -0,0 +1,5 @@ +--- +"create-llama": patch +--- + +Add publisher agent to multi-agents for generating documents (PDF and HTML) diff --git a/.changeset/gorgeous-penguins-shout.md b/.changeset/gorgeous-penguins-shout.md new file mode 100644 index 00000000..26dba194 --- /dev/null +++ b/.changeset/gorgeous-penguins-shout.md @@ -0,0 +1,5 @@ +--- +"create-llama": patch +--- + +Allow tool selection for multi-agents (Python and TS) diff --git a/e2e/python/resolve_dependencies.spec.ts b/e2e/python/resolve_dependencies.spec.ts index 96864ac0..6d778a1f 100644 --- a/e2e/python/resolve_dependencies.spec.ts +++ b/e2e/python/resolve_dependencies.spec.ts @@ -33,6 +33,7 @@ if ( const toolOptions = [ "wikipedia.WikipediaToolSpec", "google.GoogleSearchToolSpec", + "document_generator", ]; const dataSources = [ diff --git a/e2e/utils.ts b/e2e/utils.ts index 361ad7c6..956872cf 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -109,7 +109,9 @@ export async function runCreateLlama({ if (appType) { commandArgs.push(appType); } - if (!useLlamaParse) { + if (useLlamaParse) { + commandArgs.push("--use-llama-parse"); + } else { commandArgs.push("--no-llama-parse"); } diff --git a/helpers/env-variables.ts b/helpers/env-variables.ts index 11beb0e8..49e0ab87 100644 --- a/helpers/env-variables.ts +++ b/helpers/env-variables.ts @@ -426,34 +426,35 @@ const getToolEnvs = (tools?: Tool[]): EnvVar[] => { const getSystemPromptEnv = ( tools?: Tool[], dataSources?: TemplateDataSource[], - framework?: TemplateFramework, + template?: TemplateType, ): EnvVar[] => { const defaultSystemPrompt = "You are a helpful assistant who helps users with their questions."; + const systemPromptEnv: EnvVar[] = []; // build tool system prompt by merging all tool system prompts - let toolSystemPrompt = ""; - tools?.forEach((tool) => { - const toolSystemPromptEnv = tool.envVars?.find( - (env) => env.name === TOOL_SYSTEM_PROMPT_ENV_VAR, - ); - if (toolSystemPromptEnv) { - toolSystemPrompt += toolSystemPromptEnv.value + "\n"; - } - }); + // multiagent template doesn't need system prompt + if (template !== "multiagent") { + let toolSystemPrompt = ""; + tools?.forEach((tool) => { + const toolSystemPromptEnv = tool.envVars?.find( + (env) => env.name === TOOL_SYSTEM_PROMPT_ENV_VAR, + ); + if (toolSystemPromptEnv) { + toolSystemPrompt += toolSystemPromptEnv.value + "\n"; + } + }); - const systemPrompt = toolSystemPrompt - ? `\"${toolSystemPrompt}\"` - : defaultSystemPrompt; + const systemPrompt = toolSystemPrompt + ? `\"${toolSystemPrompt}\"` + : defaultSystemPrompt; - const systemPromptEnv = [ - { + systemPromptEnv.push({ name: "SYSTEM_PROMPT", description: "The system prompt for the AI model.", value: systemPrompt, - }, - ]; - + }); + } if (tools?.length == 0 && (dataSources?.length ?? 0 > 0)) { const citationPrompt = `'You have provided information from a knowledge base that has been passed to you in nodes of information. Each node has useful metadata such as node ID, file name, page, etc. @@ -559,7 +560,7 @@ export const createBackendEnvFile = async ( ...getToolEnvs(opts.tools), ...getTemplateEnvs(opts.template), ...getObservabilityEnvs(opts.observability), - ...getSystemPromptEnv(opts.tools, opts.dataSources, opts.framework), + ...getSystemPromptEnv(opts.tools, opts.dataSources, opts.template), ]; // Render and write env file const content = renderEnvVar(envVars); diff --git a/helpers/python.ts b/helpers/python.ts index f5dac282..4af47434 100644 --- a/helpers/python.ts +++ b/helpers/python.ts @@ -364,7 +364,12 @@ export const installPythonTemplate = async ({ | "modelConfig" >) => { console.log("\nInitializing Python project with template:", template, "\n"); - const templatePath = path.join(templatesDir, "types", template, framework); + let templatePath; + if (template === "extractor") { + templatePath = path.join(templatesDir, "types", "extractor", framework); + } else { + templatePath = path.join(templatesDir, "types", "streaming", framework); + } await copy("**", root, { parents: true, cwd: templatePath, @@ -401,23 +406,42 @@ export const installPythonTemplate = async ({ cwd: path.join(compPath, "services", "python"), }); } - - if (template === "streaming") { - // For the streaming template only: + // Copy engine code + if (template === "streaming" || template === "multiagent") { // Select and copy engine code based on data sources and tools let engine; - if (dataSources.length > 0 && (!tools || tools.length === 0)) { - console.log("\nNo tools selected - use optimized context chat engine\n"); - engine = "chat"; - } else { + // Multiagent always uses agent engine + if (template === "multiagent") { engine = "agent"; + } else { + // For streaming, use chat engine by default + // Unless tools are selected, in which case use agent engine + if (dataSources.length > 0 && (!tools || tools.length === 0)) { + console.log( + "\nNo tools selected - use optimized context chat engine\n", + ); + engine = "chat"; + } else { + engine = "agent"; + } } + + // Copy engine code await copy("**", enginePath, { parents: true, cwd: path.join(compPath, "engines", "python", engine), }); } + if (template === "multiagent") { + // Copy multi-agent code + await copy("**", path.join(root), { + parents: true, + cwd: path.join(compPath, "multiagent", "python"), + rename: assetRelocator, + }); + } + console.log("Adding additional dependencies"); const addOnDependencies = getAdditionalDependencies( diff --git a/helpers/tools.ts b/helpers/tools.ts index a635e3fd..97bde8b6 100644 --- a/helpers/tools.ts +++ b/helpers/tools.ts @@ -110,6 +110,29 @@ For better results, you can specify the region parameter to get results from a s }, ], }, + { + display: "Document generator", + name: "document_generator", + supportedFrameworks: ["fastapi", "nextjs", "express"], + dependencies: [ + { + name: "xhtml2pdf", + version: "^0.2.14", + }, + { + name: "markdown", + version: "^3.7", + }, + ], + type: ToolType.LOCAL, + envVars: [ + { + name: TOOL_SYSTEM_PROMPT_ENV_VAR, + description: "System prompt for document generator tool.", + value: `If user request for a report or a post, use document generator tool to create a file and reply with the link to the file.`, + }, + ], + }, { display: "Code Interpreter", name: "interpreter", diff --git a/helpers/typescript.ts b/helpers/typescript.ts index ffebae4a..90d2079e 100644 --- a/helpers/typescript.ts +++ b/helpers/typescript.ts @@ -157,7 +157,10 @@ export const installTSTemplate = async ({ // Select and copy engine code based on data sources and tools let engine; tools = tools ?? []; - if (dataSources.length > 0 && tools.length === 0) { + // multiagent template always uses agent engine + if (template === "multiagent") { + engine = "agent"; + } else if (dataSources.length > 0 && tools.length === 0) { console.log("\nNo tools selected - use optimized context chat engine\n"); engine = "chat"; } else { diff --git a/questions.ts b/questions.ts index 3619447c..81061f83 100644 --- a/questions.ts +++ b/questions.ts @@ -141,12 +141,10 @@ export const getDataSourceChoices = ( }); } if (selectedDataSource === undefined || selectedDataSource.length === 0) { - if (template !== "multiagent") { - choices.push({ - title: "No datasource", - value: "none", - }); - } + choices.push({ + title: "No datasource", + value: "none", + }); choices.push({ title: process.platform !== "linux" @@ -734,8 +732,10 @@ export const askQuestions = async ( } } - if (!program.tools && program.template === "streaming") { - // TODO: allow to select tools also for multi-agent framework + if ( + !program.tools && + (program.template === "streaming" || program.template === "multiagent") + ) { if (ciInfo.isCI) { program.tools = getPrefOrDefault("tools"); } else { diff --git a/templates/components/engines/python/agent/engine.py b/templates/components/engines/python/agent/engine.py index 22a30d0e..c71d3704 100644 --- a/templates/components/engines/python/agent/engine.py +++ b/templates/components/engines/python/agent/engine.py @@ -8,7 +8,7 @@ from llama_index.core.settings import Settings from llama_index.core.tools.query_engine import QueryEngineTool -def get_chat_engine(filters=None, params=None, event_handlers=None): +def get_chat_engine(filters=None, params=None, event_handlers=None, **kwargs): system_prompt = os.getenv("SYSTEM_PROMPT") top_k = int(os.getenv("TOP_K", 0)) tools = [] diff --git a/templates/components/engines/python/agent/tools/__init__.py b/templates/components/engines/python/agent/tools/__init__.py index f24d988d..6b218432 100644 --- a/templates/components/engines/python/agent/tools/__init__.py +++ b/templates/components/engines/python/agent/tools/__init__.py @@ -1,8 +1,9 @@ +import importlib import os + import yaml -import importlib -from llama_index.core.tools.tool_spec.base import BaseToolSpec from llama_index.core.tools.function_tool import FunctionTool +from llama_index.core.tools.tool_spec.base import BaseToolSpec class ToolType: @@ -40,14 +41,26 @@ class ToolFactory: raise ValueError(f"Failed to load tool {tool_name}: {e}") @staticmethod - def from_env() -> list[FunctionTool]: - tools = [] + def from_env( + map_result: bool = False, + ) -> list[FunctionTool] | dict[str, FunctionTool]: + """ + Load tools from the configured file. + Params: + - use_map: if True, return map of tool name and the tool itself + """ + if map_result: + tools = {} + else: + tools = [] if os.path.exists("config/tools.yaml"): with open("config/tools.yaml", "r") as f: tool_configs = yaml.safe_load(f) for tool_type, config_entries in tool_configs.items(): for tool_name, config in config_entries.items(): - tools.extend( - ToolFactory.load_tools(tool_type, tool_name, config) - ) + tool = ToolFactory.load_tools(tool_type, tool_name, config) + if map_result: + tools[tool_name] = tool + else: + tools.extend(tool) return tools diff --git a/templates/components/engines/python/agent/tools/document_generator.py b/templates/components/engines/python/agent/tools/document_generator.py new file mode 100644 index 00000000..5609f146 --- /dev/null +++ b/templates/components/engines/python/agent/tools/document_generator.py @@ -0,0 +1,229 @@ +import logging +import os +import re +from enum import Enum +from io import BytesIO + +from llama_index.core.tools.function_tool import FunctionTool + +OUTPUT_DIR = "output/tools" + + +class DocumentType(Enum): + PDF = "pdf" + HTML = "html" + + +COMMON_STYLES = """ +body { + font-family: Arial, sans-serif; + line-height: 1.3; + color: #333; +} +h1, h2, h3, h4, h5, h6 { + margin-top: 1em; + margin-bottom: 0.5em; +} +p { + margin-bottom: 0.7em; +} +code { + background-color: #f4f4f4; + padding: 2px 4px; + border-radius: 4px; +} +pre { + background-color: #f4f4f4; + padding: 10px; + border-radius: 4px; + overflow-x: auto; +} +table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1em; +} +th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} +th { + background-color: #f2f2f2; + font-weight: bold; +} +""" + +HTML_SPECIFIC_STYLES = """ +body { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} +""" + +PDF_SPECIFIC_STYLES = """ +@page { + size: letter; + margin: 2cm; +} +body { + font-size: 11pt; +} +h1 { font-size: 18pt; } +h2 { font-size: 16pt; } +h3 { font-size: 14pt; } +h4, h5, h6 { font-size: 12pt; } +pre, code { + font-family: Courier, monospace; + font-size: 0.9em; +} +""" + +HTML_TEMPLATE = """ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <style> + {common_styles} + {specific_styles} + </style> +</head> +<body> + {content} +</body> +</html> +""" + + +class DocumentGenerator: + @classmethod + def _generate_html_content(cls, original_content: str) -> str: + """ + Generate HTML content from the original markdown content. + """ + try: + import markdown + except ImportError: + raise ImportError( + "Failed to import required modules. Please install markdown." + ) + + # Convert markdown to HTML with fenced code and table extensions + html_content = markdown.markdown( + original_content, extensions=["fenced_code", "tables"] + ) + return html_content + + @classmethod + def _generate_pdf(cls, html_content: str) -> BytesIO: + """ + Generate a PDF from the HTML content. + """ + try: + from xhtml2pdf import pisa + except ImportError: + raise ImportError( + "Failed to import required modules. Please install xhtml2pdf." + ) + + pdf_html = HTML_TEMPLATE.format( + common_styles=COMMON_STYLES, + specific_styles=PDF_SPECIFIC_STYLES, + content=html_content, + ) + + buffer = BytesIO() + pdf = pisa.pisaDocument( + BytesIO(pdf_html.encode("UTF-8")), buffer, encoding="UTF-8" + ) + + if pdf.err: + logging.error(f"PDF generation failed: {pdf.err}") + raise ValueError("PDF generation failed") + + buffer.seek(0) + return buffer + + @classmethod + def _generate_html(cls, html_content: str) -> str: + """ + Generate a complete HTML document with the given HTML content. + """ + return HTML_TEMPLATE.format( + common_styles=COMMON_STYLES, + specific_styles=HTML_SPECIFIC_STYLES, + content=html_content, + ) + + @classmethod + def generate_document( + cls, original_content: str, document_type: str, file_name: str + ) -> str: + """ + To generate document as PDF or HTML file. + Parameters: + original_content: str (markdown style) + document_type: str (pdf or html) specify the type of the file format based on the use case + file_name: str (name of the document file) must be a valid file name, no extensions needed + Returns: + str (URL to the document file): A file URL ready to serve. + """ + try: + document_type = DocumentType(document_type.lower()) + except ValueError: + raise ValueError( + f"Invalid document type: {document_type}. Must be 'pdf' or 'html'." + ) + # Always generate html content first + html_content = cls._generate_html_content(original_content) + + # Based on the type of document, generate the corresponding file + if document_type == DocumentType.PDF: + content = cls._generate_pdf(html_content) + file_extension = "pdf" + elif document_type == DocumentType.HTML: + content = BytesIO(cls._generate_html(html_content).encode("utf-8")) + file_extension = "html" + else: + raise ValueError(f"Unexpected document type: {document_type}") + + file_name = cls._validate_file_name(file_name) + file_path = os.path.join(OUTPUT_DIR, f"{file_name}.{file_extension}") + + cls._write_to_file(content, file_path) + + file_url = f"{os.getenv('FILESERVER_URL_PREFIX')}/{file_path}" + return file_url + + @staticmethod + def _write_to_file(content: BytesIO, file_path: str): + """ + Write the content to a file. + """ + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "wb") as file: + file.write(content.getvalue()) + except Exception as e: + raise e + + @staticmethod + def _validate_file_name(file_name: str) -> str: + """ + Validate the file name. + """ + # Don't allow directory traversal + if os.path.isabs(file_name): + raise ValueError("File name is not allowed.") + # Don't allow special characters + if re.match(r"^[a-zA-Z0-9_.-]+$", file_name): + return file_name + else: + raise ValueError("File name is not allowed to contain special characters.") + + +def get_tools(**kwargs): + return [FunctionTool.from_defaults(DocumentGenerator.generate_document)] diff --git a/templates/components/engines/python/agent/tools/duckduckgo.py b/templates/components/engines/python/agent/tools/duckduckgo.py index b63612a7..ec0f6332 100644 --- a/templates/components/engines/python/agent/tools/duckduckgo.py +++ b/templates/components/engines/python/agent/tools/duckduckgo.py @@ -32,5 +32,37 @@ def duckduckgo_search( return results +def duckduckgo_image_search( + query: str, + region: str = "wt-wt", + max_results: int = 10, +): + """ + Use this function to search for images in DuckDuckGo. + Args: + query (str): The query to search in DuckDuckGo. + region Optional(str): The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc... + max_results Optional(int): The maximum number of results to be returned. Default is 10. + """ + try: + from duckduckgo_search import DDGS + except ImportError: + raise ImportError( + "duckduckgo_search package is required to use this function." + "Please install it by running: `poetry add duckduckgo_search` or `pip install duckduckgo_search`" + ) + params = { + "keywords": query, + "region": region, + "max_results": max_results, + } + with DDGS() as ddg: + results = list(ddg.images(**params)) + return results + + def get_tools(**kwargs): - return [FunctionTool.from_defaults(duckduckgo_search)] + return [ + FunctionTool.from_defaults(duckduckgo_search), + FunctionTool.from_defaults(duckduckgo_image_search), + ] diff --git a/templates/components/engines/python/agent/tools/img_gen.py b/templates/components/engines/python/agent/tools/img_gen.py index 966e95d0..8c2ae7bc 100644 --- a/templates/components/engines/python/agent/tools/img_gen.py +++ b/templates/components/engines/python/agent/tools/img_gen.py @@ -1,10 +1,11 @@ +import logging import os import uuid -import logging -import requests from typing import Optional -from pydantic import BaseModel, Field + +import requests from llama_index.core.tools import FunctionTool +from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -26,7 +27,7 @@ class ImageGeneratorToolOutput(BaseModel): class ImageGeneratorTool: _IMG_OUTPUT_FORMAT = "webp" - _IMG_OUTPUT_DIR = "output/tool" + _IMG_OUTPUT_DIR = "output/tools" _IMG_GEN_API = "https://api.stability.ai/v2beta/stable-image/generate/core" def __init__(self, api_key: str = None): diff --git a/templates/components/engines/python/agent/tools/interpreter.py b/templates/components/engines/python/agent/tools/interpreter.py index 8e701c58..0f4c10b9 100644 --- a/templates/components/engines/python/agent/tools/interpreter.py +++ b/templates/components/engines/python/agent/tools/interpreter.py @@ -1,13 +1,13 @@ -import os -import logging import base64 +import logging +import os import uuid -from pydantic import BaseModel -from typing import List, Dict, Optional -from llama_index.core.tools import FunctionTool +from typing import Dict, List, Optional + from e2b_code_interpreter import CodeInterpreter from e2b_code_interpreter.models import Logs - +from llama_index.core.tools import FunctionTool +from pydantic import BaseModel logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ class E2BToolOutput(BaseModel): class E2BCodeInterpreter: - output_dir = "output/tool" + output_dir = "output/tools" def __init__(self, api_key: str = None): if api_key is None: diff --git a/templates/components/engines/python/chat/engine.py b/templates/components/engines/python/chat/engine.py index cb7e0082..83c73cc6 100644 --- a/templates/components/engines/python/chat/engine.py +++ b/templates/components/engines/python/chat/engine.py @@ -9,7 +9,7 @@ from llama_index.core.memory import ChatMemoryBuffer from llama_index.core.settings import Settings -def get_chat_engine(filters=None, params=None, event_handlers=None): +def get_chat_engine(filters=None, params=None, event_handlers=None, **kwargs): system_prompt = os.getenv("SYSTEM_PROMPT") citation_prompt = os.getenv("SYSTEM_CITATION_PROMPT", None) top_k = int(os.getenv("TOP_K", 0)) diff --git a/templates/components/engines/typescript/agent/tools/document-generator.ts b/templates/components/engines/typescript/agent/tools/document-generator.ts new file mode 100644 index 00000000..b630db2c --- /dev/null +++ b/templates/components/engines/typescript/agent/tools/document-generator.ts @@ -0,0 +1,142 @@ +import { JSONSchemaType } from "ajv"; +import { BaseTool, ToolMetadata } from "llamaindex"; +import { marked } from "marked"; +import path from "node:path"; +import { saveDocument } from "../../llamaindex/documents/helper"; + +const OUTPUT_DIR = "output/tools"; + +type DocumentParameter = { + originalContent: string; + fileName: string; +}; + +const DEFAULT_METADATA: ToolMetadata<JSONSchemaType<DocumentParameter>> = { + name: "document_generator", + description: + "Generate HTML document from markdown content. Return a file url to the document", + parameters: { + type: "object", + properties: { + originalContent: { + type: "string", + description: "The original markdown content to convert.", + }, + fileName: { + type: "string", + description: "The name of the document file (without extension).", + }, + }, + required: ["originalContent", "fileName"], + }, +}; + +const COMMON_STYLES = ` + body { + font-family: Arial, sans-serif; + line-height: 1.3; + color: #333; + } + h1, h2, h3, h4, h5, h6 { + margin-top: 1em; + margin-bottom: 0.5em; + } + p { + margin-bottom: 0.7em; + } + code { + background-color: #f4f4f4; + padding: 2px 4px; + border-radius: 4px; + } + pre { + background-color: #f4f4f4; + padding: 10px; + border-radius: 4px; + overflow-x: auto; + } + table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1em; + } + th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; + } + th { + background-color: #f2f2f2; + font-weight: bold; + } + img { + max-width: 90%; + height: auto; + display: block; + margin: 1em auto; + border-radius: 10px; + } +`; + +const HTML_SPECIFIC_STYLES = ` + body { + max-width: 800px; + margin: 0 auto; + padding: 20px; + } +`; + +const HTML_TEMPLATE = ` +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <style> + ${COMMON_STYLES} + ${HTML_SPECIFIC_STYLES} + </style> +</head> +<body> + {{content}} +</body> +</html> +`; + +export interface DocumentGeneratorParams { + metadata?: ToolMetadata<JSONSchemaType<DocumentParameter>>; +} + +export class DocumentGenerator implements BaseTool<DocumentParameter> { + metadata: ToolMetadata<JSONSchemaType<DocumentParameter>>; + + constructor(params: DocumentGeneratorParams) { + this.metadata = params.metadata ?? DEFAULT_METADATA; + } + + private static async generateHtmlContent( + originalContent: string, + ): Promise<string> { + return await marked(originalContent); + } + + private static generateHtmlDocument(htmlContent: string): string { + return HTML_TEMPLATE.replace("{{content}}", htmlContent); + } + + async call(input: DocumentParameter): Promise<string> { + const { originalContent, fileName } = input; + + const htmlContent = + await DocumentGenerator.generateHtmlContent(originalContent); + const fileContent = DocumentGenerator.generateHtmlDocument(htmlContent); + + const filePath = path.join(OUTPUT_DIR, `${fileName}.html`); + + return `URL: ${await saveDocument(filePath, fileContent)}`; + } +} + +export function getTools(): BaseTool[] { + return [new DocumentGenerator({})]; +} diff --git a/templates/components/engines/typescript/agent/tools/duckduckgo.ts b/templates/components/engines/typescript/agent/tools/duckduckgo.ts index 19423e35..419ff90a 100644 --- a/templates/components/engines/typescript/agent/tools/duckduckgo.ts +++ b/templates/components/engines/typescript/agent/tools/duckduckgo.ts @@ -5,15 +5,19 @@ import { BaseTool, ToolMetadata } from "llamaindex"; export type DuckDuckGoParameter = { query: string; region?: string; + maxResults?: number; }; export type DuckDuckGoToolParams = { metadata?: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>>; }; -const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>> = { - name: "duckduckgo", - description: "Use this function to search for any query in DuckDuckGo.", +const DEFAULT_SEARCH_METADATA: ToolMetadata< + JSONSchemaType<DuckDuckGoParameter> +> = { + name: "duckduckgo_search", + description: + "Use this function to search for information (only text) in the internet using DuckDuckGo.", parameters: { type: "object", properties: { @@ -27,6 +31,12 @@ const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>> = { "Optional, The region to be used for the search in [country-language] convention, ex us-en, uk-en, ru-ru, etc...", nullable: true, }, + maxResults: { + type: "number", + description: + "Optional, The maximum number of results to be returned. Default is 10.", + nullable: true, + }, }, required: ["query"], }, @@ -42,15 +52,18 @@ export class DuckDuckGoSearchTool implements BaseTool<DuckDuckGoParameter> { metadata: ToolMetadata<JSONSchemaType<DuckDuckGoParameter>>; constructor(params: DuckDuckGoToolParams) { - this.metadata = params.metadata ?? DEFAULT_META_DATA; + this.metadata = params.metadata ?? DEFAULT_SEARCH_METADATA; } async call(input: DuckDuckGoParameter) { - const { query, region } = input; + const { query, region, maxResults = 10 } = input; const options = region ? { region } : {}; + // Temporarily sleep to reduce overloading the DuckDuckGo + await new Promise((resolve) => setTimeout(resolve, 1000)); + const searchResults = await search(query, options); - return searchResults.results.map((result) => { + return searchResults.results.slice(0, maxResults).map((result) => { return { title: result.title, description: result.description, @@ -59,3 +72,7 @@ export class DuckDuckGoSearchTool implements BaseTool<DuckDuckGoParameter> { }); } } + +export function getTools() { + return [new DuckDuckGoSearchTool({})]; +} diff --git a/templates/components/engines/typescript/agent/tools/img-gen.ts b/templates/components/engines/typescript/agent/tools/img-gen.ts index 05cc12ab..d24d5567 100644 --- a/templates/components/engines/typescript/agent/tools/img-gen.ts +++ b/templates/components/engines/typescript/agent/tools/img-gen.ts @@ -37,7 +37,7 @@ const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<ImgGeneratorParameter>> = { export class ImgGeneratorTool implements BaseTool<ImgGeneratorParameter> { readonly IMG_OUTPUT_FORMAT = "webp"; - readonly IMG_OUTPUT_DIR = "output/tool"; + readonly IMG_OUTPUT_DIR = "output/tools"; readonly IMG_GEN_API = "https://api.stability.ai/v2beta/stable-image/generate/core"; diff --git a/templates/components/engines/typescript/agent/tools/index.ts b/templates/components/engines/typescript/agent/tools/index.ts index c442a315..b29af048 100644 --- a/templates/components/engines/typescript/agent/tools/index.ts +++ b/templates/components/engines/typescript/agent/tools/index.ts @@ -1,5 +1,9 @@ import { BaseToolWithCall } from "llamaindex"; import { ToolsFactory } from "llamaindex/tools/ToolsFactory"; +import { + DocumentGenerator, + DocumentGeneratorParams, +} from "./document-generator"; import { DuckDuckGoSearchTool, DuckDuckGoToolParams } from "./duckduckgo"; import { ImgGeneratorTool, ImgGeneratorToolParams } from "./img-gen"; import { InterpreterTool, InterpreterToolParams } from "./interpreter"; @@ -43,6 +47,9 @@ const toolFactory: Record<string, ToolCreator> = { img_gen: async (config: unknown) => { return [new ImgGeneratorTool(config as ImgGeneratorToolParams)]; }, + document_generator: async (config: unknown) => { + return [new DocumentGenerator(config as DocumentGeneratorParams)]; + }, }; async function createLocalTools( diff --git a/templates/components/engines/typescript/agent/tools/interpreter.ts b/templates/components/engines/typescript/agent/tools/interpreter.ts index 6870e548..24573c20 100644 --- a/templates/components/engines/typescript/agent/tools/interpreter.ts +++ b/templates/components/engines/typescript/agent/tools/interpreter.ts @@ -56,7 +56,7 @@ const DEFAULT_META_DATA: ToolMetadata<JSONSchemaType<InterpreterParameter>> = { }; export class InterpreterTool implements BaseTool<InterpreterParameter> { - private readonly outputDir = "output/tool"; + private readonly outputDir = "output/tools"; private apiKey?: string; private fileServerURLPrefix?: string; metadata: ToolMetadata<JSONSchemaType<InterpreterParameter>>; diff --git a/templates/types/multiagent/fastapi/README-template.md b/templates/components/multiagent/python/README-template.md similarity index 100% rename from templates/types/multiagent/fastapi/README-template.md rename to templates/components/multiagent/python/README-template.md diff --git a/templates/types/multiagent/fastapi/app/agents/multi.py b/templates/components/multiagent/python/app/agents/multi.py similarity index 89% rename from templates/types/multiagent/fastapi/app/agents/multi.py rename to templates/components/multiagent/python/app/agents/multi.py index 9a04a3da..11503850 100644 --- a/templates/types/multiagent/fastapi/app/agents/multi.py +++ b/templates/components/multiagent/python/app/agents/multi.py @@ -8,7 +8,7 @@ from app.agents.single import ( ) from llama_index.core.tools.types import ToolMetadata, ToolOutput from llama_index.core.tools.utils import create_schema_from_function -from llama_index.core.workflow import Context, Workflow +from llama_index.core.workflow import Context, StopEvent, Workflow class AgentCallTool(ContextAwareTool): @@ -25,7 +25,11 @@ class AgentCallTool(ContextAwareTool): name=name, description=( f"Use this tool to delegate a sub task to the {agent.name} agent." - + (f" The agent is an {agent.role}." if agent.role else "") + + ( + f" The agent is an {agent.description}." + if agent.description + else "" + ) ), fn_schema=fn_schema, ) @@ -35,7 +39,8 @@ class AgentCallTool(ContextAwareTool): handler = self.agent.run(input=input) # bubble all events while running the agent to the calling agent async for ev in handler.stream_events(): - ctx.write_event_to_stream(ev) + if type(ev) is not StopEvent: + ctx.write_event_to_stream(ev) ret: AgentRunResult = await handler response = ret.response.message.content return ToolOutput( diff --git a/templates/types/multiagent/fastapi/app/agents/planner.py b/templates/components/multiagent/python/app/agents/planner.py similarity index 86% rename from templates/types/multiagent/fastapi/app/agents/planner.py rename to templates/components/multiagent/python/app/agents/planner.py index 07306f2b..ce9ba01e 100644 --- a/templates/types/multiagent/fastapi/app/agents/planner.py +++ b/templates/components/multiagent/python/app/agents/planner.py @@ -11,6 +11,7 @@ from llama_index.core.agent.runner.planner import ( SubTask, ) from llama_index.core.bridge.pydantic import ValidationError +from llama_index.core.chat_engine.types import ChatMessage from llama_index.core.llms.function_calling import FunctionCallingLLM from llama_index.core.prompts import PromptTemplate from llama_index.core.settings import Settings @@ -24,6 +25,18 @@ from llama_index.core.workflow import ( step, ) +INITIAL_PLANNER_PROMPT = """\ +Think step-by-step. Given a conversation, set of tools and a user request. Your responsibility is to create a plan to complete the task. +The plan must adapt with the user request and the conversation. It's fine to just start with needed tasks first and asking user for the next step approval. + +The tools available are: +{tools_str} + +Conversation: {chat_history} + +Overall Task: {task} +""" + class ExecutePlanEvent(Event): pass @@ -62,14 +75,21 @@ class StructuredPlannerAgent(Workflow): tools: List[BaseTool] | None = None, timeout: float = 360.0, refine_plan: bool = False, + chat_history: Optional[List[ChatMessage]] = None, **kwargs: Any, ) -> None: super().__init__(*args, timeout=timeout, **kwargs) self.name = name self.refine_plan = refine_plan + self.chat_history = chat_history self.tools = tools or [] - self.planner = Planner(llm=llm, tools=self.tools, verbose=self._verbose) + self.planner = Planner( + llm=llm, + tools=self.tools, + initial_plan_prompt=INITIAL_PLANNER_PROMPT, + verbose=self._verbose, + ) # The executor is keeping the memory of all tool calls and decides to call the right tool for the task self.executor = FunctionCallingAgent( name="executor", @@ -89,7 +109,9 @@ class StructuredPlannerAgent(Workflow): ctx.data["streaming"] = getattr(ev, "streaming", False) ctx.data["task"] = ev.input - plan_id, plan = await self.planner.create_plan(input=ev.input) + plan_id, plan = await self.planner.create_plan( + input=ev.input, chat_history=self.chat_history + ) ctx.data["act_plan_id"] = plan_id # inform about the new plan @@ -106,11 +128,12 @@ class StructuredPlannerAgent(Workflow): ctx.data["act_plan_id"] ) - ctx.data["num_sub_tasks"] = len(upcoming_sub_tasks) - # send an event per sub task - events = [SubTaskEvent(sub_task=sub_task) for sub_task in upcoming_sub_tasks] - for event in events: - ctx.send_event(event) + if upcoming_sub_tasks: + # Execute only the first sub-task + # otherwise the executor will get over-lapping messages + # alternatively, we could use one executor for all sub tasks + next_sub_task = upcoming_sub_tasks[0] + return SubTaskEvent(sub_task=next_sub_task) return None @@ -120,7 +143,7 @@ class StructuredPlannerAgent(Workflow): ) -> SubTaskResultEvent: if self._verbose: print(f"=== Executing sub task: {ev.sub_task.name} ===") - is_last_tasks = ctx.data["num_sub_tasks"] == self.get_remaining_subtasks(ctx) + is_last_tasks = self.get_remaining_subtasks(ctx) == 1 # TODO: streaming only works without plan refining streaming = is_last_tasks and ctx.data["streaming"] and not self.refine_plan handler = self.executor.run( @@ -142,22 +165,17 @@ class StructuredPlannerAgent(Workflow): async def gather_results( self, ctx: Context, ev: SubTaskResultEvent ) -> ExecutePlanEvent | StopEvent: - # wait for all sub tasks to finish - num_sub_tasks = ctx.data["num_sub_tasks"] - results = ctx.collect_events(ev, [SubTaskResultEvent] * num_sub_tasks) - if results is None: - return None + result = ev upcoming_sub_tasks = self.get_upcoming_sub_tasks(ctx) # if no more tasks to do, stop workflow and send result of last step if upcoming_sub_tasks == 0: - return StopEvent(result=results[-1].result) + return StopEvent(result=result.result) if self.refine_plan: - # store all results for refining the plan + # store the result for refining the plan ctx.data["results"] = ctx.data.get("results", {}) - for result in results: - ctx.data["results"][result.sub_task.name] = result.result + ctx.data["results"][result.sub_task.name] = result.result new_plan = await self.planner.refine_plan( ctx.data["task"], ctx.data["act_plan_id"], ctx.data["results"] @@ -213,7 +231,9 @@ class Planner: plan_refine_prompt = PromptTemplate(plan_refine_prompt) self.plan_refine_prompt = plan_refine_prompt - async def create_plan(self, input: str) -> Tuple[str, Plan]: + async def create_plan( + self, input: str, chat_history: Optional[List[ChatMessage]] = None + ) -> Tuple[str, Plan]: tools = self.tools tools_str = "" for tool in tools: @@ -225,6 +245,7 @@ class Planner: self.initial_plan_prompt, tools_str=tools_str, task=input, + chat_history=chat_history, ) except (ValueError, ValidationError): if self.verbose: diff --git a/templates/types/multiagent/fastapi/app/agents/single.py b/templates/components/multiagent/python/app/agents/single.py similarity index 97% rename from templates/types/multiagent/fastapi/app/agents/single.py rename to templates/components/multiagent/python/app/agents/single.py index b47662f8..a598bdf6 100644 --- a/templates/types/multiagent/fastapi/app/agents/single.py +++ b/templates/components/multiagent/python/app/agents/single.py @@ -5,10 +5,8 @@ from llama_index.core.llms import ChatMessage, ChatResponse from llama_index.core.llms.function_calling import FunctionCallingLLM from llama_index.core.memory import ChatMemoryBuffer from llama_index.core.settings import Settings -from llama_index.core.tools import ToolOutput, ToolSelection +from llama_index.core.tools import FunctionTool, ToolOutput, ToolSelection from llama_index.core.tools.types import BaseTool -from llama_index.core.tools import FunctionTool - from llama_index.core.workflow import ( Context, Event, @@ -64,14 +62,14 @@ class FunctionCallingAgent(Workflow): timeout: float = 360.0, name: str, write_events: bool = True, - role: Optional[str] = None, + description: str | None = None, **kwargs: Any, ) -> None: super().__init__(*args, verbose=verbose, timeout=timeout, **kwargs) self.tools = tools or [] self.name = name - self.role = role self.write_events = write_events + self.description = description if llm is None: llm = Settings.llm diff --git a/templates/components/multiagent/python/app/api/routers/chat.py b/templates/components/multiagent/python/app/api/routers/chat.py new file mode 100644 index 00000000..23135c80 --- /dev/null +++ b/templates/components/multiagent/python/app/api/routers/chat.py @@ -0,0 +1,46 @@ +import logging + +from app.api.routers.events import EventCallbackHandler +from app.api.routers.models import ( + ChatData, +) +from app.api.routers.vercel_response import VercelStreamResponse +from app.engine import get_chat_engine +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status + +chat_router = r = APIRouter() + +logger = logging.getLogger("uvicorn") + + +@r.post("") +async def chat( + request: Request, + data: ChatData, + background_tasks: BackgroundTasks, +): + try: + last_message_content = data.get_last_message_content() + messages = data.get_history_messages(include_agent_messages=True) + + event_handler = EventCallbackHandler() + # The chat API supports passing private document filters and chat params + # but agent workflow does not support them yet + # ignore chat params and use all documents for now + # TODO: generate filters based on doc_ids + # TODO: use chat params + engine = get_chat_engine(chat_history=messages) + + event_handler = engine.run(input=last_message_content, streaming=True) + return VercelStreamResponse( + request=request, + chat_data=data, + event_handler=event_handler, + events=engine.stream_events(), + ) + except Exception as e: + logger.exception("Error in chat engine", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error in chat engine: {e}", + ) from e diff --git a/templates/types/multiagent/fastapi/app/api/routers/vercel_response.py b/templates/components/multiagent/python/app/api/routers/vercel_response.py similarity index 66% rename from templates/types/multiagent/fastapi/app/api/routers/vercel_response.py rename to templates/components/multiagent/python/app/api/routers/vercel_response.py index 29bcf852..12082496 100644 --- a/templates/types/multiagent/fastapi/app/api/routers/vercel_response.py +++ b/templates/components/multiagent/python/app/api/routers/vercel_response.py @@ -1,6 +1,6 @@ import json import logging -from asyncio import Task +from abc import ABC from typing import AsyncGenerator, List from aiostream import stream @@ -13,93 +13,80 @@ from fastapi.responses import StreamingResponse logger = logging.getLogger("uvicorn") -class VercelStreamResponse(StreamingResponse): +class VercelStreamResponse(StreamingResponse, ABC): """ - Class to convert the response from the chat engine to the streaming format expected by Vercel + Base class to convert the response from the chat engine to the streaming format expected by Vercel """ TEXT_PREFIX = "0:" DATA_PREFIX = "8:" - @classmethod - def convert_text(cls, token: str): - # Escape newlines and double quotes to avoid breaking the stream - token = json.dumps(token) - return f"{cls.TEXT_PREFIX}{token}\n" + def __init__(self, request: Request, chat_data: ChatData, *args, **kwargs): + self.request = request - @classmethod - def convert_data(cls, data: dict): - data_str = json.dumps(data) - return f"{cls.DATA_PREFIX}[{data_str}]\n" + stream = self._create_stream(request, chat_data, *args, **kwargs) + content = self.content_generator(stream) - def __init__( - self, - request: Request, - task: Task[AgentRunResult | AsyncGenerator], - events: AsyncGenerator[AgentRunEvent, None], - chat_data: ChatData, - verbose: bool = True, - ): - content = VercelStreamResponse.content_generator( - request, task, events, chat_data, verbose - ) super().__init__(content=content) - @classmethod - async def content_generator( - cls, + async def content_generator(self, stream): + is_stream_started = False + + async with stream.stream() as streamer: + async for output in streamer: + if not is_stream_started: + is_stream_started = True + # Stream a blank message to start the stream + yield self.convert_text("") + + yield output + + if await self.request.is_disconnected(): + break + + def _create_stream( + self, request: Request, - task: Task[AgentRunResult | AsyncGenerator], - events: AsyncGenerator[AgentRunEvent, None], chat_data: ChatData, + event_handler: AgentRunResult | AsyncGenerator, + events: AsyncGenerator[AgentRunEvent, None], verbose: bool = True, ): # Yield the text response async def _chat_response_generator(): - result = await task + result = await event_handler final_response = "" if isinstance(result, AgentRunResult): for token in result.response.message.content: final_response += token - yield cls.convert_text(token) + yield self.convert_text(token) if isinstance(result, AsyncGenerator): async for token in result: final_response += token.delta - yield cls.convert_text(token.delta) + yield self.convert_text(token.delta) # Generate next questions if next question prompt is configured - question_data = await cls._generate_next_questions( + question_data = await self._generate_next_questions( chat_data.messages, final_response ) if question_data: - yield cls.convert_data(question_data) + yield self.convert_data(question_data) # TODO: stream sources # Yield the events from the event handler async def _event_generator(): - async for event in events(): - event_response = cls._event_to_response(event) + async for event in events: + event_response = self._event_to_response(event) if verbose: logger.debug(event_response) if event_response is not None: - yield cls.convert_data(event_response) + yield self.convert_data(event_response) combine = stream.merge(_chat_response_generator(), _event_generator()) - - is_stream_started = False - async with combine.stream() as streamer: - if not is_stream_started: - is_stream_started = True - # Stream a blank message to start the stream - yield cls.convert_text("") - - async for output in streamer: - yield output - if await request.is_disconnected(): - break + return combine @staticmethod def _event_to_response(event: AgentRunEvent) -> dict: @@ -108,6 +95,17 @@ class VercelStreamResponse(StreamingResponse): "data": {"agent": event.name, "text": event.msg}, } + @classmethod + def convert_text(cls, token: str): + # Escape newlines and double quotes to avoid breaking the stream + token = json.dumps(token) + return f"{cls.TEXT_PREFIX}{token}\n" + + @classmethod + def convert_data(cls, data: dict): + data_str = json.dumps(data) + return f"{cls.DATA_PREFIX}[{data_str}]\n" + @staticmethod async def _generate_next_questions(chat_history: List[Message], response: str): questions = await NextQuestionSuggestion.suggest_next_questions( diff --git a/templates/types/multiagent/fastapi/app/examples/factory.py b/templates/components/multiagent/python/app/engine/engine.py similarity index 81% rename from templates/types/multiagent/fastapi/app/examples/factory.py rename to templates/components/multiagent/python/app/engine/engine.py index 2a376a32..e9563975 100644 --- a/templates/types/multiagent/fastapi/app/examples/factory.py +++ b/templates/components/multiagent/python/app/engine/engine.py @@ -1,20 +1,20 @@ import logging +import os from typing import List, Optional + from app.examples.choreography import create_choreography from app.examples.orchestrator import create_orchestrator from app.examples.workflow import create_workflow - - -from llama_index.core.workflow import Workflow from llama_index.core.chat_engine.types import ChatMessage - - -import os +from llama_index.core.workflow import Workflow logger = logging.getLogger("uvicorn") -def create_agent(chat_history: Optional[List[ChatMessage]] = None) -> Workflow: +def get_chat_engine( + chat_history: Optional[List[ChatMessage]] = None, **kwargs +) -> Workflow: + # TODO: the EXAMPLE_TYPE could be passed as a chat config parameter? agent_type = os.getenv("EXAMPLE_TYPE", "").lower() match agent_type: case "choreography": diff --git a/templates/components/multiagent/python/app/examples/choreography.py b/templates/components/multiagent/python/app/examples/choreography.py new file mode 100644 index 00000000..13da60e5 --- /dev/null +++ b/templates/components/multiagent/python/app/examples/choreography.py @@ -0,0 +1,32 @@ +from textwrap import dedent +from typing import List, Optional + +from app.agents.multi import AgentCallingAgent +from app.agents.single import FunctionCallingAgent +from app.examples.publisher import create_publisher +from app.examples.researcher import create_researcher +from llama_index.core.chat_engine.types import ChatMessage + + +def create_choreography(chat_history: Optional[List[ChatMessage]] = None): + researcher = create_researcher(chat_history) + publisher = create_publisher(chat_history) + reviewer = FunctionCallingAgent( + name="reviewer", + description="expert in reviewing blog posts, needs a written post to review", + system_prompt="You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. If the post is good, you can say 'The post is good.'", + chat_history=chat_history, + ) + return AgentCallingAgent( + name="writer", + agents=[researcher, reviewer, publisher], + description="expert in writing blog posts, needs researched information and images to write a blog post", + system_prompt=dedent(""" + You are an expert in writing blog posts. You are given a task to write a blog post. Before starting to write the post, consult the researcher agent to get the information you need. Don't make up any information yourself. + After creating a draft for the post, send it to the reviewer agent to receive feedback and make sure to incorporate the feedback from the reviewer. + You can consult the reviewer and researcher a maximum of two times. Your output should contain only the blog post. + Finally, always request the publisher to create a document (PDF, HTML) and publish the blog post. + """), + # TODO: add chat_history support to AgentCallingAgent + # chat_history=chat_history, + ) diff --git a/templates/components/multiagent/python/app/examples/orchestrator.py b/templates/components/multiagent/python/app/examples/orchestrator.py new file mode 100644 index 00000000..8786dcd3 --- /dev/null +++ b/templates/components/multiagent/python/app/examples/orchestrator.py @@ -0,0 +1,40 @@ +from textwrap import dedent +from typing import List, Optional + +from app.agents.multi import AgentOrchestrator +from app.agents.single import FunctionCallingAgent +from app.examples.publisher import create_publisher +from app.examples.researcher import create_researcher +from llama_index.core.chat_engine.types import ChatMessage + + +def create_orchestrator(chat_history: Optional[List[ChatMessage]] = None): + researcher = create_researcher(chat_history) + writer = FunctionCallingAgent( + name="writer", + description="expert in writing blog posts, need information and images to write a post", + system_prompt=dedent(""" + You are an expert in writing blog posts. + You are given a task to write a blog post. Do not make up any information yourself. + If you don't have the necessary information to write a blog post, reply "I need information about the topic to write the blog post". + If you need to use images, reply "I need images about the topic to write the blog post". Do not use any dummy images made up by you. + If you have all the information needed, write the blog post. + """), + chat_history=chat_history, + ) + reviewer = FunctionCallingAgent( + name="reviewer", + description="expert in reviewing blog posts, needs a written blog post to review", + system_prompt=dedent(""" + You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post and fix any issues found yourself. You must output a final blog post. + A post must include at least one valid image. If not, reply "I need images about the topic to write the blog post". An image URL starting with "example" or "your website" is not valid. + Especially check for logical inconsistencies and proofread the post for grammar and spelling errors. + """), + chat_history=chat_history, + ) + publisher = create_publisher(chat_history) + return AgentOrchestrator( + agents=[writer, reviewer, researcher, publisher], + refine_plan=False, + chat_history=chat_history, + ) diff --git a/templates/components/multiagent/python/app/examples/publisher.py b/templates/components/multiagent/python/app/examples/publisher.py new file mode 100644 index 00000000..2a170d88 --- /dev/null +++ b/templates/components/multiagent/python/app/examples/publisher.py @@ -0,0 +1,35 @@ +from textwrap import dedent +from typing import List, Tuple + +from app.agents.single import FunctionCallingAgent +from app.engine.tools import ToolFactory +from llama_index.core.chat_engine.types import ChatMessage +from llama_index.core.tools import FunctionTool + + +def get_publisher_tools() -> Tuple[List[FunctionTool], str, str]: + tools = [] + # Get configured tools from the tools.yaml file + configured_tools = ToolFactory.from_env(map_result=True) + if "document_generator" in configured_tools.keys(): + tools.extend(configured_tools["document_generator"]) + prompt_instructions = dedent(""" + Normally, reply the blog post content to the user directly. + But if user requested to generate a file, use the document_generator tool to generate the file and reply the link to the file. + """) + description = "Expert in publishing the blog post, able to publish the blog post in PDF or HTML format." + else: + prompt_instructions = "You don't have a tool to generate document. Please reply the content directly." + description = "Expert in publishing the blog post" + return tools, prompt_instructions, description + + +def create_publisher(chat_history: List[ChatMessage]): + tools, prompt_instructions, description = get_publisher_tools() + return FunctionCallingAgent( + name="publisher", + tools=tools, + description=description, + system_prompt=prompt_instructions, + chat_history=chat_history, + ) diff --git a/templates/components/multiagent/python/app/examples/researcher.py b/templates/components/multiagent/python/app/examples/researcher.py new file mode 100644 index 00000000..6efa70e9 --- /dev/null +++ b/templates/components/multiagent/python/app/examples/researcher.py @@ -0,0 +1,82 @@ +import os +from textwrap import dedent +from typing import List + +from app.agents.single import FunctionCallingAgent +from app.engine.index import get_index +from app.engine.tools import ToolFactory +from llama_index.core.chat_engine.types import ChatMessage +from llama_index.core.tools import QueryEngineTool, ToolMetadata + + +def _create_query_engine_tool() -> QueryEngineTool: + """ + Provide an agent worker that can be used to query the index. + """ + index = get_index() + if index is None: + return None + top_k = int(os.getenv("TOP_K", 0)) + query_engine = index.as_query_engine( + **({"similarity_top_k": top_k} if top_k != 0 else {}) + ) + return QueryEngineTool( + query_engine=query_engine, + metadata=ToolMetadata( + name="query_index", + description=""" + Use this tool to retrieve information about the text corpus from the index. + """, + ), + ) + + +def _get_research_tools() -> QueryEngineTool: + """ + Researcher take responsibility for retrieving information. + Try init wikipedia or duckduckgo tool if available. + """ + tools = [] + query_engine_tool = _create_query_engine_tool() + if query_engine_tool is not None: + tools.append(query_engine_tool) + researcher_tool_names = ["duckduckgo", "wikipedia.WikipediaToolSpec"] + configured_tools = ToolFactory.from_env(map_result=True) + for tool_name, tool in configured_tools.items(): + if tool_name in researcher_tool_names: + tools.extend(tool) + return tools + + +def create_researcher(chat_history: List[ChatMessage]): + """ + Researcher is an agent that take responsibility for using tools to complete a given task. + """ + tools = _get_research_tools() + return FunctionCallingAgent( + name="researcher", + tools=tools, + description="expert in retrieving any unknown content or searching for images from the internet", + system_prompt=dedent(""" + You are a researcher agent. You are given a research task. + + If the conversation already includes the information and there is no new request for additional information from the user, you should return the appropriate content to the writer. + Otherwise, you must use tools to retrieve information or images needed for the task. + + It's normal for the task to include some ambiguity. You must always think carefully about the context of the user's request to understand what are the main content needs to be retrieved. + Example: + Request: "Create a blog post about the history of the internet, write in English and publish in PDF format." + ->Though: The main content is "history of the internet", while "write in English and publish in PDF format" is a requirement for other agents. + Your task: Look for information in English about the history of the Internet. + This is not your task: Create a blog post or look for how to create a PDF. + + Next request: "Publish the blog post in HTML format." + ->Though: User just asking for a format change, the previous content is still valid. + Your task: Return the previous content of the post to the writer. No need to do any research. + This is not your task: Look for how to create an HTML file. + + If you use the tools but don't find any related information, please return "I didn't find any new information for {the topic}." along with the content you found. Don't try to make up information yourself. + If the request doesn't need any new information because it was in the conversation history, please return "The task doesn't need any new information. Please reuse the existing content in the conversation history." + """), + chat_history=chat_history, + ) diff --git a/templates/components/multiagent/python/app/examples/workflow.py b/templates/components/multiagent/python/app/examples/workflow.py new file mode 100644 index 00000000..9fffb450 --- /dev/null +++ b/templates/components/multiagent/python/app/examples/workflow.py @@ -0,0 +1,250 @@ +from textwrap import dedent +from typing import AsyncGenerator, List, Optional +from llama_index.core.settings import Settings +from llama_index.core.prompts import PromptTemplate + +from app.agents.single import AgentRunEvent, AgentRunResult, FunctionCallingAgent +from app.examples.publisher import create_publisher +from app.examples.researcher import create_researcher +from llama_index.core.chat_engine.types import ChatMessage +from llama_index.core.workflow import ( + Context, + Event, + StartEvent, + StopEvent, + Workflow, + step, +) + + +def create_workflow(chat_history: Optional[List[ChatMessage]] = None): + researcher = create_researcher( + chat_history=chat_history, + ) + publisher = create_publisher( + chat_history=chat_history, + ) + writer = FunctionCallingAgent( + name="writer", + description="expert in writing blog posts, need information and images to write a post.", + system_prompt=dedent( + """ + You are an expert in writing blog posts. + You are given the task of writing a blog post based on research content provided by the researcher agent. Do not invent any information yourself. + It's important to read the entire conversation history to write the blog post accurately. + If you receive a review from the reviewer, update the post according to the feedback and return the new post content. + If the user requests an update with new information but no research content is provided, you must respond with: "I don't have any research content to write about." + If the content is not valid (e.g., broken link, broken image, etc.), do not use it. + It's normal for the task to include some ambiguity, so you must define the user's initial request to write the post correctly. + If you update the post based on the reviewer's feedback, first explain what changes you made to the post, then provide the new post content. Do not include the reviewer's comments. + Example: + Task: "Here is the information I found about the history of the internet: + Create a blog post about the history of the internet, write in English, and publish in PDF format." + -> Your task: Use the research content {...} to write a blog post in English. + -> This is not your task: Create a PDF + Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid. + """ + ), + chat_history=chat_history, + ) + reviewer = FunctionCallingAgent( + name="reviewer", + description="expert in reviewing blog posts, needs a written blog post to review.", + system_prompt=dedent( + """ + You are an expert in reviewing blog posts. + You are given a task to review a blog post. As a reviewer, it's important that your review aligns with the user's request. Please focus on the user's request when reviewing the post. + Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. + Furthermore, proofread the post for grammar and spelling errors. + Only if the post is good enough for publishing should you return 'The post is good.' In all other cases, return your review. + It's normal for the task to include some ambiguity, so you must define the user's initial request to review the post correctly. + Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid. + Example: + Task: "Create a blog post about the history of the internet, write in English and publish in PDF format." + -> Your task: Review whether the main content of the post is about the history of the internet and if it is written in English. + -> This is not your task: Create blog post, create PDF, write in English. + """ + ), + chat_history=chat_history, + ) + workflow = BlogPostWorkflow( + timeout=360, chat_history=chat_history + ) # Pass chat_history here + workflow.add_workflows( + researcher=researcher, + writer=writer, + reviewer=reviewer, + publisher=publisher, + ) + return workflow + + +class ResearchEvent(Event): + input: str + + +class WriteEvent(Event): + input: str + is_good: bool = False + + +class ReviewEvent(Event): + input: str + + +class PublishEvent(Event): + input: str + + +class BlogPostWorkflow(Workflow): + def __init__( + self, timeout: int = 360, chat_history: Optional[List[ChatMessage]] = None + ): + super().__init__(timeout=timeout) + self.chat_history = chat_history or [] + + @step() + async def start(self, ctx: Context, ev: StartEvent) -> ResearchEvent | PublishEvent: + # set streaming + ctx.data["streaming"] = getattr(ev, "streaming", False) + # start the workflow with researching about a topic + ctx.data["task"] = ev.input + ctx.data["user_input"] = ev.input + + # Decision-making process + decision = await self._decide_workflow(ev.input, self.chat_history) + + if decision != "publish": + return ResearchEvent(input=f"Research for this task: {ev.input}") + else: + chat_history_str = "\n".join( + [f"{msg.role}: {msg.content}" for msg in self.chat_history] + ) + return PublishEvent( + input=f"Please publish content based on the chat history\n{chat_history_str}\n\n and task: {ev.input}" + ) + + async def _decide_workflow( + self, input: str, chat_history: List[ChatMessage] + ) -> str: + prompt_template = PromptTemplate( + "Given the following chat history and new task, decide whether to publish based on existing information.\n" + "Chat history:\n{chat_history}\n" + "New task: {input}\n" + "Decision (respond with either 'not_publish' or 'publish'):" + ) + + chat_history_str = "\n".join( + [f"{msg.role}: {msg.content}" for msg in chat_history] + ) + prompt = prompt_template.format(chat_history=chat_history_str, input=input) + + output = await Settings.llm.acomplete(prompt) + decision = output.text.strip().lower() + + return "publish" if decision == "publish" else "research" + + @step() + async def research( + self, ctx: Context, ev: ResearchEvent, researcher: FunctionCallingAgent + ) -> WriteEvent: + result: AgentRunResult = await self.run_agent(ctx, researcher, ev.input) + content = result.response.message.content + return WriteEvent( + input=f"Write a blog post given this task: {ctx.data['task']} using this research content: {content}" + ) + + @step() + async def write( + self, ctx: Context, ev: WriteEvent, writer: FunctionCallingAgent + ) -> ReviewEvent | StopEvent: + MAX_ATTEMPTS = 2 + ctx.data["attempts"] = ctx.data.get("attempts", 0) + 1 + too_many_attempts = ctx.data["attempts"] > MAX_ATTEMPTS + if too_many_attempts: + ctx.write_event_to_stream( + AgentRunEvent( + name=writer.name, + msg=f"Too many attempts ({MAX_ATTEMPTS}) to write the blog post. Proceeding with the current version.", + ) + ) + if ev.is_good or too_many_attempts: + # too many attempts or the blog post is good - stream final response if requested + result = await self.run_agent( + ctx, writer, ev.input, streaming=ctx.data["streaming"] + ) + return StopEvent(result=result) + result: AgentRunResult = await self.run_agent(ctx, writer, ev.input) + ctx.data["result"] = result + return ReviewEvent(input=result.response.message.content) + + @step() + async def review( + self, ctx: Context, ev: ReviewEvent, reviewer: FunctionCallingAgent + ) -> WriteEvent: + result: AgentRunResult = await self.run_agent(ctx, reviewer, ev.input) + review = result.response.message.content + old_content = ctx.data["result"].response.message.content + post_is_good = "post is good" in review.lower() + ctx.write_event_to_stream( + AgentRunEvent( + name=reviewer.name, + msg=f"The post is {'not ' if not post_is_good else ''}good enough for publishing. Sending back to the writer{' for publication.' if post_is_good else '.'}", + ) + ) + if post_is_good: + return WriteEvent( + input=f"You're blog post is ready for publication. Please respond with just the blog post. Blog post: ```{old_content}```", + is_good=True, + ) + else: + return WriteEvent( + input=dedent( + f""" + Improve the writing of a given blog post by using a given review. + Blog post: + ``` + {old_content} + ``` + + Review: + ``` + {review} + ``` + """ + ), + ) + + @step() + async def publish( + self, + ctx: Context, + ev: PublishEvent, + publisher: FunctionCallingAgent, + ) -> StopEvent: + try: + result: AgentRunResult = await self.run_agent(ctx, publisher, ev.input) + return StopEvent(result=result) + except Exception as e: + ctx.write_event_to_stream( + AgentRunEvent( + name=publisher.name, + msg=f"Error publishing: {e}", + ) + ) + return StopEvent(result=None) + + async def run_agent( + self, + ctx: Context, + agent: FunctionCallingAgent, + input: str, + streaming: bool = False, + ) -> AgentRunResult | AsyncGenerator: + handler = agent.run(input=input, streaming=streaming) + # bubble all events while running the executor to the planner + async for event in handler.stream_events(): + # Don't write the StopEvent from sub task to the stream + if type(event) is not StopEvent: + ctx.write_event_to_stream(event) + return await handler diff --git a/templates/components/multiagent/typescript/workflow/agents.ts b/templates/components/multiagent/typescript/workflow/agents.ts index 7abaa6d0..b62bd360 100644 --- a/templates/components/multiagent/typescript/workflow/agents.ts +++ b/templates/components/multiagent/typescript/workflow/agents.ts @@ -1,33 +1,38 @@ -import { ChatMessage, QueryEngineTool } from "llamaindex"; -import { getDataSource } from "../engine"; +import { ChatMessage } from "llamaindex"; import { FunctionCallingAgent } from "./single-agent"; - -const getQueryEngineTool = async () => { - const index = await getDataSource(); - if (!index) { - throw new Error( - "StorageContext is empty - call 'npm run generate' to generate the storage first.", - ); - } - - const topK = process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined; - return new QueryEngineTool({ - queryEngine: index.asQueryEngine({ - similarityTopK: topK, - }), - metadata: { - name: "query_index", - description: `Use this tool to retrieve information about the text corpus from the index.`, - }, - }); -}; +import { lookupTools } from "./tools"; export const createResearcher = async (chatHistory: ChatMessage[]) => { + const tools = await lookupTools([ + "query_index", + "wikipedia_tool", + "duckduckgo_search", + "image_generator", + ]); + return new FunctionCallingAgent({ name: "researcher", - tools: [await getQueryEngineTool()], - systemPrompt: - "You are a researcher agent. You are given a researching task. You must use your tools to complete the research.", + tools: tools, + systemPrompt: `You are a researcher agent. You are given a research task. + +If the conversation already includes the information and there is no new request for additional information from the user, you should return the appropriate content to the writer. +Otherwise, you must use tools to retrieve information or images needed for the task. + +It's normal for the task to include some ambiguity. You must always think carefully about the context of the user's request to understand what are the main content needs to be retrieved. +Example: + Request: "Create a blog post about the history of the internet, write in English and publish in PDF format." + ->Though: The main content is "history of the internet", while "write in English and publish in PDF format" is a requirement for other agents. + Your task: Look for information in English about the history of the Internet. + This is not your task: Create a blog post or look for how to create a PDF. + + Next request: "Publish the blog post in HTML format." + ->Though: User just asking for a format change, the previous content is still valid. + Your task: Return the previous content of the post to the writer. No need to do any research. + This is not your task: Look for how to create an HTML file. + +If you use the tools but don't find any related information, please return "I didn't find any new information for {the topic}." along with the content you found. Don't try to make up information yourself. +If the request doesn't need any new information because it was in the conversation history, please return "The task doesn't need any new information. Please reuse the existing content in the conversation history. +`, chatHistory, }); }; @@ -35,8 +40,20 @@ export const createResearcher = async (chatHistory: ChatMessage[]) => { export const createWriter = (chatHistory: ChatMessage[]) => { return new FunctionCallingAgent({ name: "writer", - systemPrompt: - "You are an expert in writing blog posts. You are given a task to write a blog post. Don't make up any information yourself.", + systemPrompt: `You are an expert in writing blog posts. +You are given the task of writing a blog post based on research content provided by the researcher agent. Do not invent any information yourself. +It's important to read the entire conversation history to write the blog post accurately. +If you receive a review from the reviewer, update the post according to the feedback and return the new post content. +If the user requests an update with new information but no research content is provided, you must respond with: "I don't have any research content to write about." +If the content is not valid (e.g., broken link, broken image, etc.), do not use it. +It's normal for the task to include some ambiguity, so you must define the user's initial request to write the post correctly. +If you update the post based on the reviewer's feedback, first explain what changes you made to the post, then provide the new post content. Do not include the reviewer's comments. +Example: + Task: "Here is the information I found about the history of the internet: + Create a blog post about the history of the internet, write in English, and publish in PDF format." + -> Your task: Use the research content {...} to write a blog post in English. + -> This is not your task: Create a PDF + Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid.`, chatHistory, }); }; @@ -44,8 +61,34 @@ export const createWriter = (chatHistory: ChatMessage[]) => { export const createReviewer = (chatHistory: ChatMessage[]) => { return new FunctionCallingAgent({ name: "reviewer", - systemPrompt: - "You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. Only if the post is good enough for publishing, then you MUST return 'The post is good.'. In all other cases return your review.", + systemPrompt: `You are an expert in reviewing blog posts. +You are given a task to review a blog post. As a reviewer, it's important that your review aligns with the user's request. Please focus on the user's request when reviewing the post. +Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. +Furthermore, proofread the post for grammar and spelling errors. +Only if the post is good enough for publishing should you return 'The post is good.' In all other cases, return your review. +It's normal for the task to include some ambiguity, so you must define the user's initial request to review the post correctly. +Please note that a localhost link is acceptable, but dummy links like "example.com" or "your-website.com" are not valid. +Example: + Task: "Create a blog post about the history of the internet, write in English and publish in PDF format." + -> Your task: Review whether the main content of the post is about the history of the internet and if it is written in English. + -> This is not your task: Create blog post, create PDF, write in English.`, + chatHistory, + }); +}; + +export const createPublisher = async (chatHistory: ChatMessage[]) => { + const tools = await lookupTools(["document_generator"]); + let systemPrompt = `You are an expert in publishing blog posts. You are given a task to publish a blog post. +If the writer says that there was an error, you should reply with the error and not publish the post.`; + if (tools.length > 0) { + systemPrompt = `${systemPrompt}. +If the user requests to generate a file, use the document_generator tool to generate the file and reply with the link to the file. +Otherwise, simply return the content of the post.`; + } + return new FunctionCallingAgent({ + name: "publisher", + tools: tools, + systemPrompt: systemPrompt, chatHistory, }); }; diff --git a/templates/components/multiagent/typescript/workflow/factory.ts b/templates/components/multiagent/typescript/workflow/factory.ts index 8853b08b..01161303 100644 --- a/templates/components/multiagent/typescript/workflow/factory.ts +++ b/templates/components/multiagent/typescript/workflow/factory.ts @@ -6,7 +6,12 @@ import { WorkflowEvent, } from "@llamaindex/core/workflow"; import { ChatMessage, ChatResponseChunk } from "llamaindex"; -import { createResearcher, createReviewer, createWriter } from "./agents"; +import { + createPublisher, + createResearcher, + createReviewer, + createWriter, +} from "./agents"; import { AgentInput, AgentRunEvent } from "./type"; const TIMEOUT = 360 * 1000; @@ -18,8 +23,49 @@ class WriteEvent extends WorkflowEvent<{ isGood: boolean; }> {} class ReviewEvent extends WorkflowEvent<{ input: string }> {} +class PublishEvent extends WorkflowEvent<{ input: string }> {} + +const prepareChatHistory = (chatHistory: 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; + + // Construct a new agent message from agent messages + // Get annotations from assistant messages + const agentAnnotations = chatHistory + .filter((msg) => msg.role === "assistant") + .flatMap((msg) => msg.annotations || []) + .filter((annotation) => annotation.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], + ]; + } + return chatHistory; +}; export const createWorkflow = (chatHistory: ChatMessage[]) => { + const chatHistoryWithAgentMessages = prepareChatHistory(chatHistory); const runAgent = async ( context: Context, agent: Workflow, @@ -42,7 +88,7 @@ export const createWorkflow = (chatHistory: ChatMessage[]) => { }; const research = async (context: Context, ev: ResearchEvent) => { - const researcher = await createResearcher(chatHistory); + const researcher = await createResearcher(chatHistoryWithAgentMessages); const researchRes = await runAgent(context, researcher, { message: ev.data.input, }); @@ -66,17 +112,12 @@ export const createWorkflow = (chatHistory: ChatMessage[]) => { } if (ev.data.isGood || tooManyAttempts) { - // The text is ready for publication, we just use the writer to stream the output - const writer = createWriter(chatHistory); - const content = context.get("result"); - - return (await runAgent(context, writer, { - message: `You're blog post is ready for publication. Please respond with just the blog post. Blog post: \`\`\`${content}\`\`\``, - streaming: true, - })) as unknown as StopEvent<AsyncGenerator<ChatResponseChunk>>; + return new PublishEvent({ + input: "Please help me to publish the blog post.", + }); } - const writer = createWriter(chatHistory); + const writer = createWriter(chatHistoryWithAgentMessages); const writeRes = await runAgent(context, writer, { message: ev.data.input, }); @@ -86,7 +127,7 @@ export const createWorkflow = (chatHistory: ChatMessage[]) => { }; const review = async (context: Context, ev: ReviewEvent) => { - const reviewer = createReviewer(chatHistory); + const reviewer = createReviewer(chatHistoryWithAgentMessages); const reviewRes = await reviewer.run( new StartEvent<AgentInput>({ input: { message: ev.data.input } }), ); @@ -123,11 +164,24 @@ export const createWorkflow = (chatHistory: ChatMessage[]) => { }); }; + const publish = async (context: Context, ev: PublishEvent) => { + const publisher = await createPublisher(chatHistoryWithAgentMessages); + + const publishResult = await runAgent(context, publisher, { + message: `${ev.data.input}`, + streaming: true, + }); + return publishResult as unknown as StopEvent< + AsyncGenerator<ChatResponseChunk> + >; + }; + const workflow = new Workflow({ timeout: TIMEOUT, validate: true }); workflow.addStep(StartEvent, start, { outputs: ResearchEvent }); workflow.addStep(ResearchEvent, research, { outputs: WriteEvent }); - workflow.addStep(WriteEvent, write, { outputs: [ReviewEvent, StopEvent] }); + workflow.addStep(WriteEvent, write, { outputs: [ReviewEvent, PublishEvent] }); workflow.addStep(ReviewEvent, review, { outputs: WriteEvent }); + workflow.addStep(PublishEvent, publish, { outputs: StopEvent }); return workflow; }; diff --git a/templates/components/multiagent/typescript/workflow/tools.ts b/templates/components/multiagent/typescript/workflow/tools.ts new file mode 100644 index 00000000..ac4e5fb9 --- /dev/null +++ b/templates/components/multiagent/typescript/workflow/tools.ts @@ -0,0 +1,52 @@ +import fs from "fs/promises"; +import { BaseToolWithCall, QueryEngineTool } from "llamaindex"; +import path from "path"; +import { getDataSource } from "../engine"; +import { createTools } from "../engine/tools/index"; + +const getQueryEngineTool = async (): Promise<QueryEngineTool | null> => { + const index = await getDataSource(); + if (!index) { + return null; + } + + const topK = process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined; + return new QueryEngineTool({ + queryEngine: index.asQueryEngine({ + similarityTopK: topK, + }), + metadata: { + name: "query_index", + 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 queryEngineTool = await getQueryEngineTool(); + if (queryEngineTool) { + tools.push(queryEngineTool); + } + + 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/types/multiagent/fastapi/app/api/__init__.py b/templates/types/multiagent/fastapi/app/api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/types/multiagent/fastapi/app/api/routers/__init__.py b/templates/types/multiagent/fastapi/app/api/routers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/templates/types/multiagent/fastapi/app/api/routers/chat.py b/templates/types/multiagent/fastapi/app/api/routers/chat.py deleted file mode 100644 index 2b7a5636..00000000 --- a/templates/types/multiagent/fastapi/app/api/routers/chat.py +++ /dev/null @@ -1,39 +0,0 @@ -import logging - -from app.api.routers.models import ( - ChatData, -) -from app.api.routers.vercel_response import VercelStreamResponse -from app.examples.factory import create_agent -from fastapi import APIRouter, HTTPException, Request, status -from llama_index.core.workflow import Workflow - -chat_router = r = APIRouter() - -logger = logging.getLogger("uvicorn") - - -@r.post("") -async def chat( - request: Request, - data: ChatData, -): - try: - last_message_content = data.get_last_message_content() - messages = data.get_history_messages() - # TODO: generate filters based on doc_ids - # for now just use all documents - # doc_ids = data.get_chat_document_ids() - # TODO: use params - # params = data.data or {} - - agent: Workflow = create_agent(chat_history=messages) - handler = agent.run(input=last_message_content, streaming=True) - - return VercelStreamResponse(request, handler, agent.stream_events, data) - except Exception as e: - logger.exception("Error in agent", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error in agent: {e}", - ) from e diff --git a/templates/types/multiagent/fastapi/app/api/routers/chat_config.py b/templates/types/multiagent/fastapi/app/api/routers/chat_config.py deleted file mode 100644 index 8d926e50..00000000 --- a/templates/types/multiagent/fastapi/app/api/routers/chat_config.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -import os - -from fastapi import APIRouter - -from app.api.routers.models import ChatConfig - - -config_router = r = APIRouter() - -logger = logging.getLogger("uvicorn") - - -@r.get("") -async def chat_config() -> ChatConfig: - starter_questions = None - conversation_starters = os.getenv("CONVERSATION_STARTERS") - if conversation_starters and conversation_starters.strip(): - starter_questions = conversation_starters.strip().split("\n") - return ChatConfig(starter_questions=starter_questions) - - -try: - from app.engine.service import LLamaCloudFileService - - logger.info("LlamaCloud is configured. Adding /config/llamacloud route.") - - @r.get("/llamacloud") - async def chat_llama_cloud_config(): - projects = LLamaCloudFileService.get_all_projects_with_pipelines() - pipeline = os.getenv("LLAMA_CLOUD_INDEX_NAME") - project = os.getenv("LLAMA_CLOUD_PROJECT_NAME") - pipeline_config = None - if pipeline and project: - pipeline_config = { - "pipeline": pipeline, - "project": project, - } - return { - "projects": projects, - "pipeline": pipeline_config, - } - -except ImportError: - logger.debug( - "LlamaCloud is not configured. Skipping adding /config/llamacloud route." - ) - pass diff --git a/templates/types/multiagent/fastapi/app/api/routers/models.py b/templates/types/multiagent/fastapi/app/api/routers/models.py deleted file mode 100644 index 29648608..00000000 --- a/templates/types/multiagent/fastapi/app/api/routers/models.py +++ /dev/null @@ -1,227 +0,0 @@ -import logging -import os -from typing import Any, Dict, List, Literal, Optional - -from llama_index.core.llms import ChatMessage, MessageRole -from llama_index.core.schema import NodeWithScore -from pydantic import BaseModel, Field, validator -from pydantic.alias_generators import to_camel - -from app.config import DATA_DIR - -logger = logging.getLogger("uvicorn") - - -class FileContent(BaseModel): - type: Literal["text", "ref"] - # If the file is pure text then the value is be a string - # otherwise, it's a list of document IDs - value: str | List[str] - - -class File(BaseModel): - id: str - content: FileContent - filename: str - filesize: int - filetype: str - - -class AnnotationFileData(BaseModel): - files: List[File] = Field( - default=[], - description="List of files", - ) - - class Config: - json_schema_extra = { - "example": { - "csvFiles": [ - { - "content": "Name, Age\nAlice, 25\nBob, 30", - "filename": "example.csv", - "filesize": 123, - "id": "123", - "type": "text/csv", - } - ] - } - } - alias_generator = to_camel - - -class Annotation(BaseModel): - type: str - data: AnnotationFileData | List[str] - - def to_content(self) -> str | None: - if self.type == "document_file": - # We only support generating context content for CSV files for now - csv_files = [file for file in self.data.files if file.filetype == "csv"] - if len(csv_files) > 0: - return "Use data from following CSV raw content\n" + "\n".join( - [f"```csv\n{csv_file.content.value}\n```" for csv_file in csv_files] - ) - else: - logger.warning( - f"The annotation {self.type} is not supported for generating context content" - ) - return None - - -class Message(BaseModel): - role: MessageRole - content: str - annotations: List[Annotation] | None = None - - -class ChatData(BaseModel): - messages: List[Message] - data: Any = None - - class Config: - json_schema_extra = { - "example": { - "messages": [ - { - "role": "user", - "content": "What standards for letters exist?", - } - ] - } - } - - @validator("messages") - def messages_must_not_be_empty(cls, v): - if len(v) == 0: - raise ValueError("Messages must not be empty") - return v - - def get_last_message_content(self) -> str: - """ - Get the content of the last message along with the data content if available. - Fallback to use data content from previous messages - """ - if len(self.messages) == 0: - raise ValueError("There is not any message in the chat") - last_message = self.messages[-1] - message_content = last_message.content - for message in reversed(self.messages): - if message.role == MessageRole.USER and message.annotations is not None: - annotation_contents = filter( - None, - [annotation.to_content() for annotation in message.annotations], - ) - if not annotation_contents: - continue - annotation_text = "\n".join(annotation_contents) - message_content = f"{message_content}\n{annotation_text}" - break - return message_content - - def get_history_messages(self) -> List[ChatMessage]: - """ - Get the history messages - """ - return [ - ChatMessage(role=message.role, content=message.content) - for message in self.messages[:-1] - ] - - def is_last_message_from_user(self) -> bool: - return self.messages[-1].role == MessageRole.USER - - def get_chat_document_ids(self) -> List[str]: - """ - Get the document IDs from the chat messages - """ - document_ids: List[str] = [] - for message in self.messages: - if message.role == MessageRole.USER and message.annotations is not None: - for annotation in message.annotations: - if ( - annotation.type == "document_file" - and annotation.data.files is not None - ): - for fi in annotation.data.files: - if fi.content.type == "ref": - document_ids += fi.content.value - return list(set(document_ids)) - - -class SourceNodes(BaseModel): - id: str - metadata: Dict[str, Any] - score: Optional[float] - text: str - url: Optional[str] - - @classmethod - def from_source_node(cls, source_node: NodeWithScore): - metadata = source_node.node.metadata - url = cls.get_url_from_metadata(metadata) - - return cls( - id=source_node.node.node_id, - metadata=metadata, - score=source_node.score, - text=source_node.node.text, # type: ignore - url=url, - ) - - @classmethod - def get_url_from_metadata(cls, metadata: Dict[str, Any]) -> str: - url_prefix = os.getenv("FILESERVER_URL_PREFIX") - if not url_prefix: - logger.warning( - "Warning: FILESERVER_URL_PREFIX not set in environment variables. Can't use file server" - ) - file_name = metadata.get("file_name") - - if file_name and url_prefix: - # file_name exists and file server is configured - pipeline_id = metadata.get("pipeline_id") - if pipeline_id: - # file is from LlamaCloud - file_name = f"{pipeline_id}${file_name}" - return f"{url_prefix}/output/llamacloud/{file_name}" - is_private = metadata.get("private", "false") == "true" - if is_private: - # file is a private upload - return f"{url_prefix}/output/uploaded/{file_name}" - # file is from calling the 'generate' script - # Get the relative path of file_path to data_dir - file_path = metadata.get("file_path") - data_dir = os.path.abspath(DATA_DIR) - if file_path and data_dir: - relative_path = os.path.relpath(file_path, data_dir) - return f"{url_prefix}/data/{relative_path}" - # fallback to URL in metadata (e.g. for websites) - return metadata.get("URL") - - @classmethod - def from_source_nodes(cls, source_nodes: List[NodeWithScore]): - return [cls.from_source_node(node) for node in source_nodes] - - -class Result(BaseModel): - result: Message - nodes: List[SourceNodes] - - -class ChatConfig(BaseModel): - starter_questions: Optional[List[str]] = Field( - default=None, - description="List of starter questions", - serialization_alias="starterQuestions", - ) - - class Config: - json_schema_extra = { - "example": { - "starterQuestions": [ - "What standards for letters exist?", - "What are the requirements for a letter to be considered a letter?", - ] - } - } diff --git a/templates/types/multiagent/fastapi/app/api/routers/upload.py b/templates/types/multiagent/fastapi/app/api/routers/upload.py deleted file mode 100644 index ccc03004..00000000 --- a/templates/types/multiagent/fastapi/app/api/routers/upload.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -from typing import List, Any - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel - -from app.api.services.file import PrivateFileService - -file_upload_router = r = APIRouter() - -logger = logging.getLogger("uvicorn") - - -class FileUploadRequest(BaseModel): - base64: str - filename: str - params: Any = None - - -@r.post("") -def upload_file(request: FileUploadRequest) -> List[str]: - try: - logger.info("Processing file") - return PrivateFileService.process_file( - request.filename, request.base64, request.params - ) - except Exception as e: - logger.error(f"Error processing file: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="Error processing file") diff --git a/templates/types/multiagent/fastapi/app/config.py b/templates/types/multiagent/fastapi/app/config.py deleted file mode 100644 index 29fa8d9a..00000000 --- a/templates/types/multiagent/fastapi/app/config.py +++ /dev/null @@ -1 +0,0 @@ -DATA_DIR = "data" diff --git a/templates/types/multiagent/fastapi/app/examples/choreography.py b/templates/types/multiagent/fastapi/app/examples/choreography.py deleted file mode 100644 index aa7c197d..00000000 --- a/templates/types/multiagent/fastapi/app/examples/choreography.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List, Optional -from app.agents.single import FunctionCallingAgent -from app.agents.multi import AgentCallingAgent -from app.examples.researcher import create_researcher -from llama_index.core.chat_engine.types import ChatMessage - - -def create_choreography(chat_history: Optional[List[ChatMessage]] = None): - researcher = create_researcher(chat_history) - reviewer = FunctionCallingAgent( - name="reviewer", - role="expert in reviewing blog posts", - system_prompt="You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. If the post is good, you can say 'The post is good.'", - chat_history=chat_history, - ) - return AgentCallingAgent( - name="writer", - agents=[researcher, reviewer], - role="expert in writing blog posts", - system_prompt="""You are an expert in writing blog posts. You are given a task to write a blog post. Before starting to write the post, consult the researcher agent to get the information you need. Don't make up any information yourself. - After creating a draft for the post, send it to the reviewer agent to receive some feedback and make sure to incorporate the feedback from the reviewer. - You can consult the reviewer and researcher maximal two times. Your output should just contain the blog post.""", - # TODO: add chat_history support to AgentCallingAgent - # chat_history=chat_history, - ) diff --git a/templates/types/multiagent/fastapi/app/examples/orchestrator.py b/templates/types/multiagent/fastapi/app/examples/orchestrator.py deleted file mode 100644 index 9f915124..00000000 --- a/templates/types/multiagent/fastapi/app/examples/orchestrator.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import List, Optional -from app.agents.single import FunctionCallingAgent -from app.agents.multi import AgentOrchestrator -from app.examples.researcher import create_researcher - -from llama_index.core.chat_engine.types import ChatMessage - - -def create_orchestrator(chat_history: Optional[List[ChatMessage]] = None): - researcher = create_researcher(chat_history) - writer = FunctionCallingAgent( - name="writer", - role="expert in writing blog posts", - system_prompt="""You are an expert in writing blog posts. You are given a task to write a blog post. Don't make up any information yourself. If you don't have the necessary information to write a blog post, reply "I need information about the topic to write the blog post". If you have all the information needed, write the blog post.""", - chat_history=chat_history, - ) - reviewer = FunctionCallingAgent( - name="reviewer", - role="expert in reviewing blog posts", - system_prompt="""You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post and fix the issues found yourself. You must output a final blog post. - Especially check for logical inconsistencies and proofread the post for grammar and spelling errors.""", - chat_history=chat_history, - ) - return AgentOrchestrator( - agents=[writer, reviewer, researcher], - refine_plan=False, - ) diff --git a/templates/types/multiagent/fastapi/app/examples/researcher.py b/templates/types/multiagent/fastapi/app/examples/researcher.py deleted file mode 100644 index ed6819d4..00000000 --- a/templates/types/multiagent/fastapi/app/examples/researcher.py +++ /dev/null @@ -1,39 +0,0 @@ -import os -from typing import List -from llama_index.core.tools import QueryEngineTool, ToolMetadata -from app.agents.single import FunctionCallingAgent -from app.engine.index import get_index - -from llama_index.core.chat_engine.types import ChatMessage - - -def get_query_engine_tool() -> QueryEngineTool: - """ - Provide an agent worker that can be used to query the index. - """ - index = get_index() - if index is None: - raise ValueError("Index not found. Please create an index first.") - top_k = int(os.getenv("TOP_K", 0)) - query_engine = index.as_query_engine( - **({"similarity_top_k": top_k} if top_k != 0 else {}) - ) - return QueryEngineTool( - query_engine=query_engine, - metadata=ToolMetadata( - name="query_index", - description=""" - Use this tool to retrieve information about the text corpus from the index. - """, - ), - ) - - -def create_researcher(chat_history: List[ChatMessage]): - return FunctionCallingAgent( - name="researcher", - tools=[get_query_engine_tool()], - role="expert in retrieving any unknown content", - system_prompt="You are a researcher agent. You are given a researching task. You must use your tools to complete the research.", - chat_history=chat_history, - ) diff --git a/templates/types/multiagent/fastapi/app/examples/workflow.py b/templates/types/multiagent/fastapi/app/examples/workflow.py deleted file mode 100644 index c92f96ab..00000000 --- a/templates/types/multiagent/fastapi/app/examples/workflow.py +++ /dev/null @@ -1,139 +0,0 @@ -from typing import AsyncGenerator, List, Optional - -from app.agents.single import AgentRunEvent, AgentRunResult, FunctionCallingAgent -from app.examples.researcher import create_researcher -from llama_index.core.chat_engine.types import ChatMessage -from llama_index.core.workflow import ( - Context, - Event, - StartEvent, - StopEvent, - Workflow, - step, -) - - -def create_workflow(chat_history: Optional[List[ChatMessage]] = None): - researcher = create_researcher( - chat_history=chat_history, - ) - writer = FunctionCallingAgent( - name="writer", - role="expert in writing blog posts", - system_prompt="""You are an expert in writing blog posts. You are given a task to write a blog post. Don't make up any information yourself.""", - chat_history=chat_history, - ) - reviewer = FunctionCallingAgent( - name="reviewer", - role="expert in reviewing blog posts", - system_prompt="You are an expert in reviewing blog posts. You are given a task to review a blog post. Review the post for logical inconsistencies, ask critical questions, and provide suggestions for improvement. Furthermore, proofread the post for grammar and spelling errors. Only if the post is good enough for publishing, then you MUST return 'The post is good.'. In all other cases return your review.", - chat_history=chat_history, - ) - workflow = BlogPostWorkflow(timeout=360) - workflow.add_workflows(researcher=researcher, writer=writer, reviewer=reviewer) - return workflow - - -class ResearchEvent(Event): - input: str - - -class WriteEvent(Event): - input: str - is_good: bool = False - - -class ReviewEvent(Event): - input: str - - -class BlogPostWorkflow(Workflow): - @step() - async def start(self, ctx: Context, ev: StartEvent) -> ResearchEvent: - # set streaming - ctx.data["streaming"] = getattr(ev, "streaming", False) - # start the workflow with researching about a topic - ctx.data["task"] = ev.input - return ResearchEvent(input=f"Research for this task: {ev.input}") - - @step() - async def research( - self, ctx: Context, ev: ResearchEvent, researcher: FunctionCallingAgent - ) -> WriteEvent: - result: AgentRunResult = await self.run_agent(ctx, researcher, ev.input) - content = result.response.message.content - return WriteEvent( - input=f"Write a blog post given this task: {ctx.data['task']} using this research content: {content}" - ) - - @step() - async def write( - self, ctx: Context, ev: WriteEvent, writer: FunctionCallingAgent - ) -> ReviewEvent | StopEvent: - MAX_ATTEMPTS = 2 - ctx.data["attempts"] = ctx.data.get("attempts", 0) + 1 - too_many_attempts = ctx.data["attempts"] > MAX_ATTEMPTS - if too_many_attempts: - ctx.write_event_to_stream( - AgentRunEvent( - name=writer.name, - msg=f"Too many attempts ({MAX_ATTEMPTS}) to write the blog post. Proceeding with the current version.", - ) - ) - if ev.is_good or too_many_attempts: - # too many attempts or the blog post is good - stream final response if requested - result = await self.run_agent( - ctx, writer, ev.input, streaming=ctx.data["streaming"] - ) - return StopEvent(result=result) - result: AgentRunResult = await self.run_agent(ctx, writer, ev.input) - ctx.data["result"] = result - return ReviewEvent(input=result.response.message.content) - - @step() - async def review( - self, ctx: Context, ev: ReviewEvent, reviewer: FunctionCallingAgent - ) -> WriteEvent: - result: AgentRunResult = await self.run_agent(ctx, reviewer, ev.input) - review = result.response.message.content - old_content = ctx.data["result"].response.message.content - post_is_good = "post is good" in review.lower() - ctx.write_event_to_stream( - AgentRunEvent( - name=reviewer.name, - msg=f"The post is {'not ' if not post_is_good else ''}good enough for publishing. Sending back to the writer{' for publication.' if post_is_good else '.'}", - ) - ) - if post_is_good: - return WriteEvent( - input=f"You're blog post is ready for publication. Please respond with just the blog post. Blog post: ```{old_content}```", - is_good=True, - ) - else: - return WriteEvent( - input=f"""Improve the writing of a given blog post by using a given review. -Blog post: -``` -{old_content} -``` - -Review: -``` -{review} -```""" - ) - - async def run_agent( - self, - ctx: Context, - agent: FunctionCallingAgent, - input: str, - streaming: bool = False, - ) -> AgentRunResult | AsyncGenerator: - handler = agent.run(input=input, streaming=streaming) - # bubble all events while running the executor to the planner - async for event in handler.stream_events(): - # Don't write the StopEvent from sub task to the stream - if type(event) is not StopEvent: - ctx.write_event_to_stream(event) - return await handler diff --git a/templates/types/multiagent/fastapi/app/observability.py b/templates/types/multiagent/fastapi/app/observability.py deleted file mode 100644 index 28019c37..00000000 --- a/templates/types/multiagent/fastapi/app/observability.py +++ /dev/null @@ -1,2 +0,0 @@ -def init_observability(): - pass diff --git a/templates/types/multiagent/fastapi/app/utils.py b/templates/types/multiagent/fastapi/app/utils.py deleted file mode 100644 index ac43ccbb..00000000 --- a/templates/types/multiagent/fastapi/app/utils.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - - -def load_from_env(var: str, throw_error: bool = True) -> str: - res = os.getenv(var) - if res is None and throw_error: - raise ValueError(f"Missing environment variable: {var}") - return res diff --git a/templates/types/multiagent/fastapi/gitignore b/templates/types/multiagent/fastapi/gitignore deleted file mode 100644 index ae22d348..00000000 --- a/templates/types/multiagent/fastapi/gitignore +++ /dev/null @@ -1,4 +0,0 @@ -__pycache__ -storage -.env -output diff --git a/templates/types/multiagent/fastapi/main.py b/templates/types/multiagent/fastapi/main.py deleted file mode 100644 index 11395a07..00000000 --- a/templates/types/multiagent/fastapi/main.py +++ /dev/null @@ -1,72 +0,0 @@ -# flake8: noqa: E402 -import os -from dotenv import load_dotenv - -from app.config import DATA_DIR - -load_dotenv() - -import logging - -import uvicorn -from app.api.routers.chat import chat_router -from app.api.routers.chat_config import config_router -from app.api.routers.upload import file_upload_router -from app.observability import init_observability -from app.settings import init_settings -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import RedirectResponse -from fastapi.staticfiles import StaticFiles - -app = FastAPI() - -init_settings() -init_observability() - - -environment = os.getenv("ENVIRONMENT", "dev") # Default to 'development' if not set -logger = logging.getLogger("uvicorn") - -if environment == "dev": - logger.warning("Running in development mode - allowing CORS for all origins") - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Redirect to documentation page when accessing base URL - @app.get("/") - async def redirect_to_docs(): - return RedirectResponse(url="/docs") - - -def mount_static_files(directory, path): - if os.path.exists(directory): - logger.info(f"Mounting static files '{directory}' at '{path}'") - app.mount( - path, - StaticFiles(directory=directory, check_dir=False), - name=f"{directory}-static", - ) - - -# Mount the data files to serve the file viewer -mount_static_files(DATA_DIR, "/api/files/data") -# Mount the output files from tools -mount_static_files("output", "/api/files/output") - -app.include_router(chat_router, prefix="/api/chat") -app.include_router(config_router, prefix="/api/chat/config") -app.include_router(file_upload_router, prefix="/api/chat/upload") - - -if __name__ == "__main__": - app_host = os.getenv("APP_HOST", "0.0.0.0") - app_port = int(os.getenv("APP_PORT", "8000")) - reload = True if environment == "dev" else False - - uvicorn.run(app="main:app", host=app_host, port=app_port, reload=reload) diff --git a/templates/types/multiagent/fastapi/pyproject.toml b/templates/types/multiagent/fastapi/pyproject.toml deleted file mode 100644 index 5c779f27..00000000 --- a/templates/types/multiagent/fastapi/pyproject.toml +++ /dev/null @@ -1,27 +0,0 @@ -[tool] -[tool.poetry] -name = "app" -version = "0.1.0" -description = "" -authors = ["Marcus Schiesser <mail@marcusschiesser.de>"] -readme = "README.md" - -[tool.poetry.scripts] -generate = "app.engine.generate:generate_datasource" - -[tool.poetry.dependencies] -python = ">=3.11,<3.13" -llama-index-agent-openai = ">=0.3.0,<0.4.0" -llama-index = "0.11.11" -fastapi = "^0.112.2" -python-dotenv = "^1.0.0" -uvicorn = { extras = ["standard"], version = "^0.23.2" } -cachetools = "^5.3.3" -aiostream = "^0.5.2" - -[tool.poetry.dependencies.docx2txt] -version = "^0.8" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" diff --git a/templates/types/streaming/express/package.json b/templates/types/streaming/express/package.json index 084f39c8..39d23f85 100644 --- a/templates/types/streaming/express/package.json +++ b/templates/types/streaming/express/package.json @@ -27,7 +27,8 @@ "@e2b/code-interpreter": "^0.0.5", "got": "^14.4.1", "@apidevtools/swagger-parser": "^10.1.0", - "formdata-node": "^6.0.3" + "formdata-node": "^6.0.3", + "marked": "^14.1.2" }, "devDependencies": { "@types/cors": "^2.8.16", diff --git a/templates/types/streaming/fastapi/app/api/routers/chat.py b/templates/types/streaming/fastapi/app/api/routers/chat.py index 39894361..48876efb 100644 --- a/templates/types/streaming/fastapi/app/api/routers/chat.py +++ b/templates/types/streaming/fastapi/app/api/routers/chat.py @@ -1,8 +1,8 @@ import logging from typing import List -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status -from llama_index.core.chat_engine.types import BaseChatEngine, NodeWithScore +from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status +from llama_index.core.chat_engine.types import NodeWithScore from llama_index.core.llms import MessageRole from app.api.routers.events import EventCallbackHandler @@ -58,11 +58,19 @@ async def chat( @r.post("/request") async def chat_request( data: ChatData, - chat_engine: BaseChatEngine = Depends(get_chat_engine), ) -> Result: last_message_content = data.get_last_message_content() messages = data.get_history_messages() + doc_ids = data.get_chat_document_ids() + filters = generate_filters(doc_ids) + params = data.data or {} + logger.info( + f"Creating chat engine with filters: {str(filters)}", + ) + + chat_engine = get_chat_engine(filters=filters, params=params) + response = await chat_engine.achat(last_message_content, messages) return Result( result=Message(role=MessageRole.ASSISTANT, content=response.response), diff --git a/templates/types/streaming/fastapi/app/api/routers/models.py b/templates/types/streaming/fastapi/app/api/routers/models.py index 29648608..123f97ba 100644 --- a/templates/types/streaming/fastapi/app/api/routers/models.py +++ b/templates/types/streaming/fastapi/app/api/routers/models.py @@ -50,9 +50,14 @@ class AnnotationFileData(BaseModel): alias_generator = to_camel +class AgentAnnotation(BaseModel): + agent: str + text: str + + class Annotation(BaseModel): type: str - data: AnnotationFileData | List[str] + data: AnnotationFileData | List[str] | AgentAnnotation def to_content(self) -> str | None: if self.type == "document_file": @@ -119,14 +124,48 @@ class ChatData(BaseModel): break return message_content - def get_history_messages(self) -> List[ChatMessage]: + def _get_agent_messages(self, max_messages: int = 10) -> List[str]: + """ + Construct agent messages from the annotations in the chat messages + """ + agent_messages = [] + for message in self.messages: + if ( + message.role == MessageRole.ASSISTANT + and message.annotations is not None + ): + for annotation in message.annotations: + if annotation.type == "agent" and isinstance( + annotation.data, AgentAnnotation + ): + text = annotation.data.text + agent_messages.append( + f"\nAgent: {annotation.data.agent}\nsaid: {text}\n" + ) + if len(agent_messages) >= max_messages: + break + return agent_messages + + def get_history_messages( + self, include_agent_messages: bool = False + ) -> List[ChatMessage]: """ Get the history messages """ - return [ + chat_messages = [ ChatMessage(role=message.role, content=message.content) for message in self.messages[:-1] ] + if include_agent_messages: + agent_messages = self._get_agent_messages(max_messages=5) + if len(agent_messages) > 0: + message = ChatMessage( + role=MessageRole.ASSISTANT, + content="Previous agent events: \n" + "\n".join(agent_messages), + ) + chat_messages.append(message) + + return chat_messages def is_last_message_from_user(self) -> bool: return self.messages[-1].role == MessageRole.USER diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx index 618dd064..8fea31df 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message/chat-agent-events.tsx @@ -17,6 +17,7 @@ const AgentIcons: Record<string, LucideIcon> = { researcher: icons.ScanSearch, writer: icons.PenLine, reviewer: icons.MessageCircle, + publisher: icons.BookCheck, }; type MergedEvent = { diff --git a/templates/types/streaming/nextjs/package.json b/templates/types/streaming/nextjs/package.json index 775bee98..38ced851 100644 --- a/templates/types/streaming/nextjs/package.json +++ b/templates/types/streaming/nextjs/package.json @@ -42,7 +42,8 @@ "tailwind-merge": "^2.1.0", "tiktoken": "^1.0.15", "uuid": "^9.0.1", - "vaul": "^0.9.1" + "vaul": "^0.9.1", + "marked": "^14.1.2" }, "devDependencies": { "@types/node": "^20.10.3", -- GitLab