Give Khoj ability to run python code as a tool triggered via chat API

Create python code executing chat actor
- The chat actor generate python code within sandbox constraints
- Run the generated python code in the cohere terrarium, pyodide
  based sandbox accessible at sandbox url
This commit is contained in:
Debanjum Singh Solanky
2024-10-09 13:37:06 -07:00
parent 4d33239af6
commit 8044733201
9 changed files with 200 additions and 4 deletions

View File

@@ -126,6 +126,7 @@ def converse_anthropic(
references, references,
user_query, user_query,
online_results: Optional[Dict[str, Dict]] = None, online_results: Optional[Dict[str, Dict]] = None,
code_results: Optional[Dict[str, Dict]] = None,
conversation_log={}, conversation_log={},
model: Optional[str] = "claude-instant-1.2", model: Optional[str] = "claude-instant-1.2",
api_key: Optional[str] = None, api_key: Optional[str] = None,
@@ -175,6 +176,10 @@ def converse_anthropic(
completion_func(chat_response=prompts.no_online_results_found.format()) completion_func(chat_response=prompts.no_online_results_found.format())
return iter([prompts.no_online_results_found.format()]) return iter([prompts.no_online_results_found.format()])
if ConversationCommand.Code in conversation_commands and not is_none_or_empty(code_results):
conversation_primer = (
f"{prompts.code_executed_context.format(code_results=str(code_results))}\n{conversation_primer}"
)
if ConversationCommand.Online in conversation_commands or ConversationCommand.Webpage in conversation_commands: if ConversationCommand.Online in conversation_commands or ConversationCommand.Webpage in conversation_commands:
conversation_primer = ( conversation_primer = (
f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}" f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}"

View File

@@ -122,6 +122,7 @@ def converse_gemini(
references, references,
user_query, user_query,
online_results: Optional[Dict[str, Dict]] = None, online_results: Optional[Dict[str, Dict]] = None,
code_results: Optional[Dict[str, Dict]] = None,
conversation_log={}, conversation_log={},
model: Optional[str] = "gemini-1.5-flash", model: Optional[str] = "gemini-1.5-flash",
api_key: Optional[str] = None, api_key: Optional[str] = None,
@@ -173,6 +174,10 @@ def converse_gemini(
completion_func(chat_response=prompts.no_online_results_found.format()) completion_func(chat_response=prompts.no_online_results_found.format())
return iter([prompts.no_online_results_found.format()]) return iter([prompts.no_online_results_found.format()])
if ConversationCommand.Code in conversation_commands and not is_none_or_empty(code_results):
conversation_primer = (
f"{prompts.code_executed_context.format(code_results=str(code_results))}\n{conversation_primer}"
)
if ConversationCommand.Online in conversation_commands or ConversationCommand.Webpage in conversation_commands: if ConversationCommand.Online in conversation_commands or ConversationCommand.Webpage in conversation_commands:
conversation_primer = ( conversation_primer = (
f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}" f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}"

View File

@@ -135,7 +135,8 @@ def filter_questions(questions: List[str]):
def converse_offline( def converse_offline(
user_query, user_query,
references=[], references=[],
online_results=[], online_results={},
code_results={},
conversation_log={}, conversation_log={},
model: str = "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF", model: str = "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
loaded_model: Union[Any, None] = None, loaded_model: Union[Any, None] = None,
@@ -187,6 +188,10 @@ def converse_offline(
completion_func(chat_response=prompts.no_online_results_found.format()) completion_func(chat_response=prompts.no_online_results_found.format())
return iter([prompts.no_online_results_found.format()]) return iter([prompts.no_online_results_found.format()])
if ConversationCommand.Code in conversation_commands and not is_none_or_empty(code_results):
conversation_primer = (
f"{prompts.code_executed_context.format(code_results=str(code_results))}\n{conversation_primer}"
)
if ConversationCommand.Online in conversation_commands: if ConversationCommand.Online in conversation_commands:
simplified_online_results = online_results.copy() simplified_online_results = online_results.copy()
for result in online_results: for result in online_results:

View File

@@ -123,6 +123,7 @@ def converse(
references, references,
user_query, user_query,
online_results: Optional[Dict[str, Dict]] = None, online_results: Optional[Dict[str, Dict]] = None,
code_results: Optional[Dict[str, Dict]] = None,
conversation_log={}, conversation_log={},
model: str = "gpt-4o-mini", model: str = "gpt-4o-mini",
api_key: Optional[str] = None, api_key: Optional[str] = None,
@@ -176,6 +177,10 @@ def converse(
completion_func(chat_response=prompts.no_online_results_found.format()) completion_func(chat_response=prompts.no_online_results_found.format())
return iter([prompts.no_online_results_found.format()]) return iter([prompts.no_online_results_found.format()])
if not is_none_or_empty(code_results):
conversation_primer = (
f"{prompts.code_executed_context.format(code_results=str(code_results))}\n{conversation_primer}"
)
if not is_none_or_empty(online_results): if not is_none_or_empty(online_results):
conversation_primer = ( conversation_primer = (
f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}" f"{prompts.online_search_conversation.format(online_results=str(online_results))}\n{conversation_primer}"

View File

@@ -730,6 +730,48 @@ Khoj:
""".strip() """.strip()
) )
# Code Generation
# --
python_code_generation_prompt = PromptTemplate.from_template(
"""
You are Khoj, an advanced python programmer. You are tasked with constructing **up to three** python programs to best answer the user query.
- The python program will run in a pyodide python sandbox with no network access.
- You can write programs to run complex calculations, analyze data, create charts, generate documents to meticulously answer the query
- The sandbox has access to the standard library, matplotlib, panda, numpy, scipy, bs4, sympy, brotli, cryptography, fast-parquet
- Do not try display images or plots in the code directly. The code should save the image or plot to a file instead.
- Write any document, charts etc. to be shared with the user to file. These files can be seen by the user.
- Use as much context from the previous questions and answers as required to generate your code.
{personality_context}
What code will you need to write, if any, to answer the user's question?
Provide code programs as a list of strings in a JSON object with key "codes".
Current Date: {current_date}
User's Location: {location}
{username}
The JSON schema is of the form {{"codes": ["code1", "code2", "code3"]}}
For example:
{{"codes": ["print('Hello, World!')", "print('Goodbye, World!')"]}}
Now it's your turn to construct python programs to answer the user's question. Provide them as a list of strings in a JSON object. Do not say anything else.
History:
{chat_history}
User: {query}
Khoj:
""".strip()
)
code_executed_context = PromptTemplate.from_template(
"""
Use the provided code executions to inform your response.
Ask crisp follow-up questions to get additional context, when a helpful response cannot be provided from the provided code execution results or past conversations.
Code Execution Results:
{code_results}
""".strip()
)
# Automations # Automations
# -- # --
crontime_prompt = PromptTemplate.from_template( crontime_prompt = PromptTemplate.from_template(

View File

@@ -104,6 +104,7 @@ def save_to_conversation_log(
user_message_time: str = None, user_message_time: str = None,
compiled_references: List[Dict[str, Any]] = [], compiled_references: List[Dict[str, Any]] = [],
online_results: Dict[str, Any] = {}, online_results: Dict[str, Any] = {},
code_results: Dict[str, Any] = {},
inferred_queries: List[str] = [], inferred_queries: List[str] = [],
intent_type: str = "remember", intent_type: str = "remember",
client_application: ClientApplication = None, client_application: ClientApplication = None,
@@ -123,6 +124,7 @@ def save_to_conversation_log(
"context": compiled_references, "context": compiled_references,
"intent": {"inferred-queries": inferred_queries, "type": intent_type}, "intent": {"inferred-queries": inferred_queries, "type": intent_type},
"onlineContext": online_results, "onlineContext": online_results,
"codeContext": code_results,
"automationId": automation_id, "automationId": automation_id,
}, },
conversation_log=meta_log.get("chat", []), conversation_log=meta_log.get("chat", []),

View File

@@ -3,7 +3,6 @@ import base64
import json import json
import logging import logging
import time import time
import warnings
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -47,6 +46,7 @@ from khoj.routers.helpers import (
is_query_empty, is_query_empty,
is_ready_to_chat, is_ready_to_chat,
read_chat_stream, read_chat_stream,
run_code,
update_telemetry_state, update_telemetry_state,
validate_conversation_config, validate_conversation_config,
) )
@@ -950,6 +950,30 @@ async def chat(
exc_info=True, exc_info=True,
) )
## Gather Code Results
if ConversationCommand.Code in conversation_commands:
try:
async for result in run_code(
defiltered_query,
meta_log,
location,
user,
partial(send_event, ChatEvent.STATUS),
uploaded_image_url=uploaded_image_url,
agent=agent,
):
if isinstance(result, dict) and ChatEvent.STATUS in result:
yield result[ChatEvent.STATUS]
else:
code_results = result
async for result in send_event(ChatEvent.STATUS, f"**Ran code snippets**: {len(code_results)}"):
yield result
except ValueError as e:
logger.warning(
f"Failed to use code tool: {e}. Attempting to respond without code results",
exc_info=True,
)
## Send Gathered References ## Send Gathered References
async for result in send_event( async for result in send_event(
ChatEvent.REFERENCES, ChatEvent.REFERENCES,
@@ -957,6 +981,7 @@ async def chat(
"inferredQueries": inferred_queries, "inferredQueries": inferred_queries,
"context": compiled_references, "context": compiled_references,
"onlineContext": online_results, "onlineContext": online_results,
"codeContext": code_results,
}, },
): ):
yield result yield result
@@ -1024,6 +1049,7 @@ async def chat(
conversation, conversation,
compiled_references, compiled_references,
online_results, online_results,
code_results,
inferred_queries, inferred_queries,
conversation_commands, conversation_commands,
user, user,

View File

@@ -24,6 +24,7 @@ from typing import (
) )
from urllib.parse import parse_qs, quote, urljoin, urlparse from urllib.parse import parse_qs, quote, urljoin, urlparse
import aiohttp
import cron_descriptor import cron_descriptor
import pytz import pytz
import requests import requests
@@ -519,6 +520,103 @@ async def generate_online_subqueries(
return [q] return [q]
async def run_code(
query: str,
conversation_history: dict,
location_data: LocationData,
user: KhojUser,
send_status_func: Optional[Callable] = None,
uploaded_image_url: str = None,
agent: Agent = None,
sandbox_url: str = "http://localhost:8080",
):
# Generate Code
if send_status_func:
async for event in send_status_func(f"**Generate code snippets** for {query}"):
yield {ChatEvent.STATUS: event}
try:
with timer("Chat actor: Generate programs to execute", logger):
codes = await generate_python_code(
query, conversation_history, location_data, user, uploaded_image_url, agent
)
except Exception as e:
raise ValueError(f"Failed to generate code for {query} with error: {e}")
# Run Code
if send_status_func:
async for event in send_status_func(f"**Running {len(codes)} code snippets**"):
yield {ChatEvent.STATUS: event}
try:
tasks = [execute_sandboxed_python(code, sandbox_url) for code in codes]
with timer("Chat actor: Execute generated programs", logger):
results = await asyncio.gather(*tasks)
for result in results:
code = result.pop("code")
logger.info(f"Executed Code:\n--@@--\n{code}\n--@@--Result:\n--@@--\n{result}\n--@@--")
yield {query: {"code": code, "results": result}}
except Exception as e:
raise ValueError(f"Failed to run code for {query} with error: {e}")
async def generate_python_code(
q: str,
conversation_history: dict,
location_data: LocationData,
user: KhojUser,
uploaded_image_url: str = None,
agent: Agent = None,
) -> List[str]:
location = f"{location_data}" if location_data else "Unknown"
username = prompts.user_name.format(name=user.get_full_name()) if user.get_full_name() else ""
chat_history = construct_chat_history(conversation_history)
utc_date = datetime.utcnow().strftime("%Y-%m-%d")
personality_context = (
prompts.personality_context.format(personality=agent.personality) if agent and agent.personality else ""
)
code_generation_prompt = prompts.python_code_generation_prompt.format(
current_date=utc_date,
query=q,
chat_history=chat_history,
location=location,
username=username,
personality_context=personality_context,
)
response = await send_message_to_model_wrapper(
code_generation_prompt, uploaded_image_url=uploaded_image_url, response_type="json_object", user=user
)
# Validate that the response is a non-empty, JSON-serializable list
response = response.strip()
response = remove_json_codeblock(response)
response = json.loads(response)
codes = [code.strip() for code in response["codes"] if code.strip()]
if not isinstance(codes, list) or not codes or len(codes) == 0:
raise ValueError
return codes
async def execute_sandboxed_python(code: str, sandbox_url: str = "http://localhost:8080") -> dict[str, Any]:
"""
Takes code to run as a string and calls the terrarium API to execute it.
Returns the result of the code execution as a dictionary.
"""
headers = {"Content-Type": "application/json"}
data = {"code": code}
async with aiohttp.ClientSession() as session:
async with session.post(sandbox_url, json=data, headers=headers) as response:
if response.status == 200:
result: dict[str, Any] = await response.json()
result["code"] = code
return result
else:
return {"code": code, "success": False, "std_err": f"Failed to execute code with {response.status}"}
async def schedule_query(q: str, conversation_history: dict, uploaded_image_url: str = None) -> Tuple[str, ...]: async def schedule_query(q: str, conversation_history: dict, uploaded_image_url: str = None) -> Tuple[str, ...]:
""" """
Schedule the date, time to run the query. Assume the server timezone is UTC. Schedule the date, time to run the query. Assume the server timezone is UTC.
@@ -949,6 +1047,7 @@ def generate_chat_response(
conversation: Conversation, conversation: Conversation,
compiled_references: List[Dict] = [], compiled_references: List[Dict] = [],
online_results: Dict[str, Dict] = {}, online_results: Dict[str, Dict] = {},
code_results: Dict[str, Dict] = {},
inferred_queries: List[str] = [], inferred_queries: List[str] = [],
conversation_commands: List[ConversationCommand] = [ConversationCommand.Default], conversation_commands: List[ConversationCommand] = [ConversationCommand.Default],
user: KhojUser = None, user: KhojUser = None,
@@ -976,6 +1075,7 @@ def generate_chat_response(
meta_log=meta_log, meta_log=meta_log,
compiled_references=compiled_references, compiled_references=compiled_references,
online_results=online_results, online_results=online_results,
code_results=code_results,
inferred_queries=inferred_queries, inferred_queries=inferred_queries,
client_application=client_application, client_application=client_application,
conversation_id=conversation_id, conversation_id=conversation_id,
@@ -1017,6 +1117,7 @@ def generate_chat_response(
query_to_run, query_to_run,
image_url=uploaded_image_url, image_url=uploaded_image_url,
online_results=online_results, online_results=online_results,
code_results=code_results,
conversation_log=meta_log, conversation_log=meta_log,
model=chat_model, model=chat_model,
api_key=api_key, api_key=api_key,
@@ -1037,6 +1138,7 @@ def generate_chat_response(
compiled_references, compiled_references,
query_to_run, query_to_run,
online_results, online_results,
code_results,
meta_log, meta_log,
model=conversation_config.chat_model, model=conversation_config.chat_model,
api_key=api_key, api_key=api_key,
@@ -1054,6 +1156,7 @@ def generate_chat_response(
compiled_references, compiled_references,
query_to_run, query_to_run,
online_results, online_results,
code_results,
meta_log, meta_log,
model=conversation_config.chat_model, model=conversation_config.chat_model,
api_key=api_key, api_key=api_key,

View File

@@ -309,6 +309,7 @@ class ConversationCommand(str, Enum):
Help = "help" Help = "help"
Online = "online" Online = "online"
Webpage = "webpage" Webpage = "webpage"
Code = "code"
Image = "image" Image = "image"
Text = "text" Text = "text"
Automation = "automation" Automation = "automation"
@@ -322,6 +323,7 @@ command_descriptions = {
ConversationCommand.Default: "The default command when no command specified. It intelligently auto-switches between general and notes mode.", ConversationCommand.Default: "The default command when no command specified. It intelligently auto-switches between general and notes mode.",
ConversationCommand.Online: "Search for information on the internet.", ConversationCommand.Online: "Search for information on the internet.",
ConversationCommand.Webpage: "Get information from webpage suggested by you.", ConversationCommand.Webpage: "Get information from webpage suggested by you.",
ConversationCommand.Code: "Run Python code to parse information, run complex calculations, create documents and charts.",
ConversationCommand.Image: "Generate images by describing your imagination in words.", ConversationCommand.Image: "Generate images by describing your imagination in words.",
ConversationCommand.Automation: "Automatically run your query at a specified time or interval.", ConversationCommand.Automation: "Automatically run your query at a specified time or interval.",
ConversationCommand.Help: "Get help with how to use or setup Khoj from the documentation", ConversationCommand.Help: "Get help with how to use or setup Khoj from the documentation",
@@ -342,6 +344,7 @@ tool_descriptions_for_llm = {
ConversationCommand.Notes: "To search the user's personal knowledge base. Especially helpful if the question expects context from the user's notes or documents.", ConversationCommand.Notes: "To search the user's personal knowledge base. Especially helpful if the question expects context from the user's notes or documents.",
ConversationCommand.Online: "To search for the latest, up-to-date information from the internet. Note: **Questions about Khoj should always use this data source**", ConversationCommand.Online: "To search for the latest, up-to-date information from the internet. Note: **Questions about Khoj should always use this data source**",
ConversationCommand.Webpage: "To use if the user has directly provided the webpage urls or you are certain of the webpage urls to read.", ConversationCommand.Webpage: "To use if the user has directly provided the webpage urls or you are certain of the webpage urls to read.",
ConversationCommand.Code: "To run Python code in a Pyodide sandbox with no network access. Helpful when need to parse information, run complex calculations, create documents and charts for user. Matplotlib, bs4, pandas, numpy, etc. are available.",
ConversationCommand.Summarize: "To retrieve an answer that depends on the entire document or a large text.", ConversationCommand.Summarize: "To retrieve an answer that depends on the entire document or a large text.",
} }
@@ -352,13 +355,13 @@ function_calling_description_for_llm = {
} }
mode_descriptions_for_llm = { mode_descriptions_for_llm = {
ConversationCommand.Image: "Use this if the user is requesting you to generate a picture based on their description.", ConversationCommand.Image: "Use this if the user is requesting you to generate images based on their description. This does not support generating charts or graphs.",
ConversationCommand.Automation: "Use this if the user is requesting a response at a scheduled date or time.", ConversationCommand.Automation: "Use this if the user is requesting a response at a scheduled date or time.",
ConversationCommand.Text: "Use this if the other response modes don't seem to fit the query.", ConversationCommand.Text: "Use this if the other response modes don't seem to fit the query.",
} }
mode_descriptions_for_agent = { mode_descriptions_for_agent = {
ConversationCommand.Image: "Agent can generate image in response.", ConversationCommand.Image: "Agent can generate images in response. It cannot not use this to generate charts and graphs.",
ConversationCommand.Text: "Agent can generate text in response.", ConversationCommand.Text: "Agent can generate text in response.",
} }