diff --git a/agent-langgraph-short-term-memory/.env.example b/agent-langgraph-short-term-memory/.env.example new file mode 100644 index 00000000..035195c5 --- /dev/null +++ b/agent-langgraph-short-term-memory/.env.example @@ -0,0 +1,20 @@ +# Make a copy of this to set environment variables for local development +# cp .env.example .env.local + +# TODO: Fill in auth related env vars +DATABRICKS_CONFIG_PROFILE=DEFAULT +# DATABRICKS_HOST=https://.databricks.com +# DATABRICKS_TOKEN=dapi.... + +# TODO: Update with the MLflow experiment you want to log traces and models to +MLFLOW_EXPERIMENT_ID= + +# TODO: Update with your Lakebase instance name for short-term memory / conversation checkpointing +LAKEBASE_INSTANCE_NAME= + +# TODO: Update the route to query agent if you used a different port to deploy your agent +API_PROXY=http://localhost:8000/invocations + +CHAT_APP_PORT=3000 +MLFLOW_TRACKING_URI="databricks" +MLFLOW_REGISTRY_URI="databricks-uc" diff --git a/agent-langgraph-short-term-memory/.gitignore b/agent-langgraph-short-term-memory/.gitignore new file mode 100644 index 00000000..63c3b98c --- /dev/null +++ b/agent-langgraph-short-term-memory/.gitignore @@ -0,0 +1,208 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +databricks.yml + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# VS Code +.vscode/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python + +# Created by https://www.toptal.com/developers/gitignore/api/react +# Edit at https://www.toptal.com/developers/gitignore?templates=react + +### react ### +.DS_* +*.log +logs +**/*.backup.* +**/*.back.* + +node_modules +bower_components + +*.sublime* + +psd +thumb +sketch + +# End of https://www.toptal.com/developers/gitignore/api/react + +**/uv.lock +**/mlruns/ +**/.vite/ +**/.databricks +**/.claude +**/.env.local diff --git a/agent-langgraph-short-term-memory/README.md b/agent-langgraph-short-term-memory/README.md new file mode 100644 index 00000000..f7ec63b7 --- /dev/null +++ b/agent-langgraph-short-term-memory/README.md @@ -0,0 +1,270 @@ +# Responses API Agent + +This template defines a conversational agent app. The app comes with a built-in chat UI, but also exposes an API endpoint for invoking the agent so that you can serve your UI elsewhere (e.g. on your website or in a mobile app). + +The agent in this template implements the [OpenAI Responses API](https://platform.openai.com/docs/api-reference/responses) interface. It has access to a single tool; the [built-in code interpreter tool](https://docs.databricks.com/aws/en/generative-ai/agent-framework/code-interpreter-tools#built-in-python-executor-tool) (`system.ai.python_exec`) on Databricks. You can customize agent code and test it via the API or UI. + +The agent input and output format are defined by MLflow's ResponsesAgent interface, which closely follows the [OpenAI Responses API](https://platform.openai.com/docs/api-reference/responses) interface. See [the MLflow docs](https://mlflow.org/docs/latest/genai/flavors/responses-agent-intro/) for input and output formats for streaming and non-streaming requests, tracing requirements, and other agent authoring details. + +## Quick start + +Run the `./scripts/quickstart.sh` script to quickly set up your local environment and start the agent server. At any step, if there are issues, refer to the manual local development loop setup below. + +This script will: + +1. Verify uv, nvm, and Databricks CLI installations +2. Configure Databricks authentication +3. Configure agent tracing, by creating and linking an MLflow experiment to your app +4. Configure short-term memory by linking a Lakebase instance for conversation store and checkpointing +5. Start the agent server and chat app + +```bash +./scripts/quickstart.sh +``` + +After the setup is complete, you can start the agent server and the chat app locally with: + +```bash +./scripts/start-app.sh +``` + +This will start the agent server and the chat app at http://localhost:8000. + +**Next steps**: see [modifying your agent](#modifying-your-agent) to customize and iterate on the agent code. + +## Manual local development loop setup + +1. **Set up your local environment** + Install `uv` (python package manager), `nvm` (node version manager), and the Databricks CLI: + + - [`uv` installation docs](https://docs.astral.sh/uv/getting-started/installation/) + - [`nvm` installation](https://github.com/nvm-sh/nvm?tab=readme-ov-file#installing-and-updating) + - Run the following to use Node 20 LTS: + ```bash + nvm use 20 + ``` + - [`databricks CLI` installation](https://docs.databricks.com/aws/en/dev-tools/cli/install) + +2. **Set up local authentication to Databricks** + + In order to access Databricks resources from your local machine while developing your agent, you need to authenticate with Databricks. Choose one of the following options: + + **Option 1: OAuth via Databricks CLI (Recommended)** + + Authenticate with Databricks using the CLI. See the [CLI OAuth documentation](https://docs.databricks.com/aws/en/dev-tools/cli/authentication#oauth-user-to-machine-u2m-authentication). + + ```bash + databricks auth login + ``` + + Set the `DATABRICKS_CONFIG_PROFILE` environment variable in your .env.local file to the profile you used to authenticate: + + ```bash + DATABRICKS_CONFIG_PROFILE="DEFAULT" # change to the profile name you chose + ``` + + **Option 2: Personal Access Token (PAT)** + + See the [PAT documentation](https://docs.databricks.com/aws/en/dev-tools/auth/pat#databricks-personal-access-tokens-for-workspace-users). + + ```bash + # Add these to your .env.local file + DATABRICKS_HOST="https://host.databricks.com" + DATABRICKS_TOKEN="dapi_token" + ``` + + See the [Databricks SDK authentication docs](https://docs.databricks.com/aws/en/dev-tools/sdk-python#authenticate-the-databricks-sdk-for-python-with-your-databricks-account-or-workspace). + +3. **Create and link an MLflow experiment to your app** + + Create an MLflow experiment to enable tracing and version tracking. This is automatically done by the `./scripts/quickstart.sh` script. + + Create the MLflow experiment via the CLI: + + ```bash + DATABRICKS_USERNAME=$(databricks current-user me | jq -r .userName) + databricks experiments create-experiment /Users/$DATABRICKS_USERNAME/agents-on-apps + ``` + + Make a copy of `.env.example` to `.env.local` and update the `MLFLOW_EXPERIMENT_ID` in your `.env.local` file with the experiment ID you created. The `.env.local` file will be automatically loaded when starting the server. + + ```bash + cp .env.example .env.local + # Edit .env.local and fill in your experiment ID + ``` + + See the [MLflow experiments documentation](https://docs.databricks.com/aws/en/mlflow/experiments#create-experiment-from-the-workspace). + +4. **Create and link a Lakebase instance for short-term memory** + + This agent uses Lakebase for conversation checkpointing, enabling short-term memory across conversation turns. You need to create a Lakebase instance and configure it for your agent. + + Create a Lakebase instance in your Databricks workspace. You can do this via the Databricks UI or CLI. See the [Lakebase documentation](https://docs.databricks.com/aws/en/oltp/instances/create/) for setup instructions. + + Once created, update the `LAKEBASE_INSTANCE_NAME` in your `.env.local` file with your instance name: + + ```bash + # Edit .env.local and fill in your Lakebase instance name + LAKEBASE_INSTANCE_NAME=your-lakebase-instance-name + ``` + + The agent will automatically create the necessary checkpoint tables in your Lakebase instance on first run. + +5. **Test your agent locally** + + Start up the agent server and chat UI locally: + + ```bash + ./scripts/start-app.sh + ``` + + Query your agent via the UI (http://localhost:8000) or REST API: + + **Advanced server options:** + + ```bash + uv run start-server --reload # hot-reload the server on code changes + uv run start-server --port 8001 # change the port the server listens on + uv run start-server --workers 4 # run the server with multiple workers + ``` + + - Example streaming request: + ```bash + curl -X POST http://localhost:8000/invocations \ + -H "Content-Type: application/json" \ + -d '{ "input": [{ "role": "user", "content": "hi" }], "stream": true }' + ``` + - Example non-streaming request: + ```bash + curl -X POST http://localhost:8000/invocations \ + -H "Content-Type: application/json" \ + -d '{ "input": [{ "role": "user", "content": "hi" }] }' + ``` + +## Modifying your agent + +See the [LangGraph documentation](https://docs.langchain.com/oss/python/langgraph/quickstart) for more information on how to edit your own agent. + +Required files for hosting with MLflow `AgentServer`: + +- `agent.py`: Contains your agent logic. Modify this file to create your custom agent. For example, you can [add agent tools](https://docs.databricks.com/aws/en/generative-ai/agent-framework/agent-tool) to give your agent additional capabilities +- `start_server.py`: Initializes and runs the MLflow `AgentServer` with agent_type="ResponsesAgent". You don't have to modify this file for most common use cases, but can add additional server routes (e.g. a `/metrics` endpoint) here + +**Common customization questions:** + +**Q: Can I add additional files or folders to my agent?** +Yes. Add additional files or folders as needed. Ensure the script within `pyproject.toml` runs the correct script that starts the server and sets up MLflow tracing. + +**Q: How do I add dependencies to my agent?** +Run `uv add ` (e.g., `uv add "mlflow-skinny[databricks]"`). See the [python pyproject.toml guide](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#dependencies-and-requirements). + +**Q: Can I add custom tracing beyond the built-in tracing?** +Yes. This template uses MLflow's agent server, which comes with automatic tracing for agent logic decorated with `@invoke()` and `@stream()`. It also uses [MLflow autologging APIs](https://mlflow.org/docs/latest/genai/tracing/#one-line-auto-tracing-integrations) to capture traces from LLM invocations. However, you can add additional instrumentation to capture more granular trace information when your agent runs. See the [MLflow tracing documentation](https://docs.databricks.com/aws/en/mlflow3/genai/tracing/app-instrumentation/). + +**Q: How can I extend this example with additional tools and capabilities?** +This template can be extended by integrating additional MCP servers, Vector Search Indexes, UC Functions, and other Databricks tools. See the ["Agent Framework Tools Documentation"](https://docs.databricks.com/aws/en/generative-ai/agent-framework/agent-tool). + +## Evaluating your agent + +Evaluate your agent by calling the invoke function you defined for the agent locally. + +- Update your `evaluate_agent.py` file with the preferred evaluation dataset and scorers. + +Run the evaluation using the evaluation script: + +```bash +uv run agent-evaluate +``` + +After it completes, open the MLflow UI link for your experiment to inspect results. + +## Deploying to Databricks Apps + +0. **Create a Databricks App**: + Ensure you have the [Databricks CLI](https://docs.databricks.com/aws/en/dev-tools/cli/tutorial) installed and configured. + + ```bash + databricks apps create agent-langgraph + ``` + +1. **Set up authentication to Databricks resources** + + For this example, you need to add an MLflow Experiment and a Lakebase instance as resources to your app. + + - **MLflow Experiment**: Navigate to the [MLFlow Experiments UI](https://docs.databricks.com/aws/en/mlflow/experiments#change-permissions-for-an-experiment) and grant the App's Service Principal (SP) permission to edit the experiment. + - **Lakebase Instance**: Grant the App's SP permission to access your Lakebase instance for conversation checkpointing. + + To access resources like serving endpoints, genie spaces, MLflow experiments, Lakebase instances, UC Functions, and Vector Search Indexes, click `edit` on your app home page to grant the App's SP permission. See the [Databricks Apps resources documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/resources). + + For resources that are not supported yet, see the [Agent Framework authentication documentation](https://docs.databricks.com/aws/en/generative-ai/agent-framework/deploy-agent#automatic-authentication-passthrough) for the correct permission level to grant to your app SP. + + **On-behalf-of (OBO) User Authentication**: Use `get_user_workspace_client()` from `agent_server.utils` to authenticate as the requesting user instead of the app service principal. See the [OBO authentication documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/auth?language=Streamlit#retrieve-user-authorization-credentials). + +2. **Make sure the values of `MLFLOW_EXPERIMENT_ID` and `LAKEBASE_INSTANCE_NAME` are set in `app.yaml`** + + The `MLFLOW_EXPERIMENT_ID` and `LAKEBASE_INSTANCE_NAME` in `app.yaml` should have been filled in by the `./scripts/quickstart.sh` script. If they are not set, you can manually fill in the values in `app.yaml`: + + ```yaml + env: + - name: MLFLOW_EXPERIMENT_ID + value: "your-experiment-id" + - name: LAKEBASE_INSTANCE_NAME + value: "your-lakebase-instance-name" + ``` + + The `MLFLOW_EXPERIMENT_ID` and `LAKEBASE_INSTANCE_NAME` in `app.yaml` should have been filled in by the `./scripts/quickstart.sh` script. If it is not set, you can manually fill in the value in `app.yaml`. Refer to the [Databricks Apps environment variable documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/environment-variables) for more info. + +3. **Sync local files to your workspace** + + See the [Databricks Apps deploy documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/deploy?language=Databricks+CLI#deploy-the-app). + + ```bash + DATABRICKS_USERNAME=$(databricks current-user me | jq -r .userName) + databricks sync . "/Users/$DATABRICKS_USERNAME/agent-langgraph" + ``` + +4. **Deploy your Databricks App** + + See the [Databricks Apps deploy documentation](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/deploy?language=Databricks+CLI#deploy-the-app). + + ```bash + databricks apps deploy agent-langgraph --source-code-path /Workspace/Users/$DATABRICKS_USERNAME/agent-langgraph + ``` + +5. **Query your agent hosted on Databricks Apps** + + Databricks Apps are _only_ queryable via OAuth token. You cannot use a PAT to query your agent. Generate an [OAuth token with your credentials using the Databricks CLI](https://docs.databricks.com/aws/en/dev-tools/cli/authentication#u2m-auth): + + ```bash + databricks auth login --host + databricks auth token + ``` + + Send a request to the `/invocations` endpoint: + + - Example streaming request: + + ```bash + curl -X POST /invocations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ "input": [{ "role": "user", "content": "hi" }], "stream": true }' + ``` + + - Example non-streaming request: + + ```bash + curl -X POST /invocations \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ "input": [{ "role": "user", "content": "hi" }] }' + ``` + +For future updates to the agent, sync and redeploy your agent. + +### FAQ + +- For a streaming response, I see a 200 OK in the logs, but an error in the actual stream. What's going on? + - This is expected behavior. The initial 200 OK confirms stream setup; streaming errors don't affect this status. +- When querying my agent, I get a 302 error. What's going on? + - Use an OAuth token. PATs are not supported for querying agents. diff --git a/agent-langgraph-short-term-memory/agent_server/__init__.py b/agent-langgraph-short-term-memory/agent_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/agent-langgraph-short-term-memory/agent_server/agent.py b/agent-langgraph-short-term-memory/agent_server/agent.py new file mode 100644 index 00000000..b05276c2 --- /dev/null +++ b/agent-langgraph-short-term-memory/agent_server/agent.py @@ -0,0 +1,312 @@ +import logging +import os +import uuid +from typing import Annotated, Any, Generator, Optional, Sequence, TypedDict + +import mlflow +from databricks_langchain import ( + ChatDatabricks, + UCFunctionToolkit, + CheckpointSaver, +) +from databricks.sdk import WorkspaceClient +from langchain_core.messages import AIMessage, AIMessageChunk, AnyMessage +from langchain_core.runnables import RunnableConfig, RunnableLambda +from langgraph.graph import END, StateGraph +from langgraph.graph.message import add_messages +from langgraph.prebuilt.tool_node import ToolNode +from mlflow.genai.agent_server import invoke, stream +from mlflow.pyfunc import ResponsesAgent +from mlflow.types.responses import ( + ResponsesAgentRequest, + ResponsesAgentResponse, + ResponsesAgentStreamEvent, + output_to_responses_items_stream, +) + +logger = logging.getLogger(__name__) +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + + +def _ensure_annotations(item: Any) -> Any: + """Ensure content items have an annotations field (required by frontend). + + The e2e-chatbot-app-next frontend expects all content items to have an + 'annotations' array, even if empty. This function adds it if missing. + """ + if item is None: + return item + + # Handle dict-like items + if hasattr(item, 'model_dump'): + item_dict = item.model_dump() + elif isinstance(item, dict): + item_dict = item + else: + return item + + # Add annotations to content items if missing + if "content" in item_dict and isinstance(item_dict["content"], list): + for content_item in item_dict["content"]: + if isinstance(content_item, dict) and "annotations" not in content_item: + content_item["annotations"] = [] + + return item_dict + +############################################ +# Define your LLM endpoint and system prompt +############################################ +# TODO: Replace with your model serving endpoint +LLM_ENDPOINT_NAME = "databricks-claude-sonnet-4-5" + +# TODO: Update with your system prompt +SYSTEM_PROMPT = """You are a helpful assistant. Use the available tools to answer questions.""" + +############################################ +# Lakebase configuration +############################################ +LAKEBASE_INSTANCE_NAME = os.getenv("LAKEBASE_INSTANCE_NAME", "") + +############################################################################### +# Define tools for your agent, enabling it to retrieve data or take actions +# beyond text generation +# To create and see usage examples of more tools, see +# https://docs.databricks.com/en/generative-ai/agent-framework/agent-tool.html +############################################################################### +tools = [] + +# Example UC tools; add your own as needed +UC_TOOL_NAMES: list[str] = [] +if UC_TOOL_NAMES: + uc_toolkit = UCFunctionToolkit(function_names=UC_TOOL_NAMES) + tools.extend(uc_toolkit.tools) + +# Use Databricks vector search indexes as tools +# See https://docs.databricks.com/en/generative-ai/agent-framework/unstructured-retrieval-tools.html +VECTOR_SEARCH_TOOLS = [] +tools.extend(VECTOR_SEARCH_TOOLS) + + +##################### +# Define agent logic +##################### + + +class AgentState(TypedDict): + """State for the agent graph.""" + messages: Annotated[Sequence[AnyMessage], add_messages] + custom_inputs: Optional[dict[str, Any]] + custom_outputs: Optional[dict[str, Any]] + + +class LangGraphResponsesAgent(ResponsesAgent): + """Stateful agent using ResponsesAgent with pooled Lakebase checkpointing. + + This agent supports conversation threading through Lakebase's CheckpointSaver. + Pass a `thread_id` in custom_inputs to resume a previous conversation, or + let the system generate a new one automatically. + + Usage via API: + # Start a new conversation (auto-generates thread_id) + curl -X POST http://localhost:8000/invocations \\ + -H "Content-Type: application/json" \\ + -d '{"input": [{"role": "user", "content": "Hello!"}]}' + + # Continue an existing conversation + curl -X POST http://localhost:8000/invocations \\ + -H "Content-Type: application/json" \\ + -d '{ + "input": [{"role": "user", "content": "What did we discuss?"}], + "custom_inputs": {"thread_id": "your-thread-id-here"} + }' + """ + + def __init__(self, lakebase_instance_name: str): + self.lakebase_instance_name = lakebase_instance_name + self.workspace_client = WorkspaceClient() + + self.model = ChatDatabricks(endpoint=LLM_ENDPOINT_NAME) + self.system_prompt = SYSTEM_PROMPT + self.model_with_tools = self.model.bind_tools(tools) if tools else self.model + + mlflow.langchain.autolog() + + def _create_graph(self, checkpointer: Any): + """Create the LangGraph workflow with checkpointing support.""" + + def should_continue(state: AgentState): + """Determine if the agent should continue to tools or end.""" + messages = state["messages"] + last_message = messages[-1] + if isinstance(last_message, AIMessage) and last_message.tool_calls: + return "continue" + return "end" + + # Preprocessor to add system prompt + preprocessor = ( + RunnableLambda( + lambda state: [{"role": "system", "content": self.system_prompt}] + state["messages"] + ) + if self.system_prompt + else RunnableLambda(lambda state: state["messages"]) + ) + model_runnable = preprocessor | self.model_with_tools + + def call_model(state: AgentState, config: RunnableConfig): + """Call the model with the current state.""" + response = model_runnable.invoke(state, config) + return {"messages": [response]} + + # Build the workflow graph + workflow = StateGraph(AgentState) + workflow.add_node("agent", RunnableLambda(call_model)) + + if tools: + workflow.add_node("tools", ToolNode(tools)) + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "end": END} + ) + workflow.add_edge("tools", "agent") + else: + workflow.add_edge("agent", END) + + workflow.set_entry_point("agent") + return workflow.compile(checkpointer=checkpointer) + + def _get_or_create_thread_id(self, request: ResponsesAgentRequest) -> str: + """Get thread_id from request or create a new one. + + Priority: + 1. Use thread_id from custom_inputs if present + 2. Use conversation_id from chat context if available + 3. Generate a new UUID + + Returns: + thread_id: The thread identifier to use for this conversation + """ + ci = dict(request.custom_inputs or {}) + + # Check custom_inputs first + if "thread_id" in ci: + logger.info(f"Using thread_id from custom_inputs: {ci['thread_id']}") + return ci["thread_id"] + + # Check conversation_id from chat context + # https://mlflow.org/docs/latest/api_reference/python_api/mlflow.types.html#mlflow.types.agent.ChatContext + if request.context and getattr(request.context, "conversation_id", None): + logger.info(f"Using conversation_id from context: {request.context.conversation_id}") + return request.context.conversation_id + + # Generate new thread_id + new_thread_id = str(uuid.uuid4()) + logger.info(f"Generated new thread_id: {new_thread_id}") + return new_thread_id + + def predict(self, request: ResponsesAgentRequest) -> ResponsesAgentResponse: + """Non-streaming prediction that collects all events from the stream.""" + outputs = [] + for event in self.predict_stream(request): + if event.type == "response.output_item.done": + # Ensure annotations field is present (required by frontend) + item = _ensure_annotations(event.item) + outputs.append(item) + + # Include thread_id in custom_outputs so caller knows which thread was used + custom_outputs = dict(request.custom_inputs or {}) + return ResponsesAgentResponse(output=outputs, custom_outputs=custom_outputs) + + def predict_stream( + self, request: ResponsesAgentRequest + ) -> Generator[ResponsesAgentStreamEvent, None, None]: + """Streaming prediction with Lakebase checkpointing for conversation history.""" + + # Get or create thread_id + thread_id = self._get_or_create_thread_id(request) + + # Update custom_inputs with thread_id so it's available in response + ci = dict(request.custom_inputs or {}) + ci["thread_id"] = thread_id + request.custom_inputs = ci + + logger.info(f"Starting message stream for chat with thread_id: {thread_id}") + + cc_msgs = self.prep_msgs_for_cc_llm([i.model_dump() for i in request.input]) + langchain_msgs = cc_msgs + checkpoint_config = {"configurable": {"thread_id": thread_id}} + + # Use CheckpointSaver context manager for Lakebase checkpointing + with CheckpointSaver(instance_name=self.lakebase_instance_name) as checkpointer: + graph = self._create_graph(checkpointer) + + for event in graph.stream( + {"messages": langchain_msgs}, + checkpoint_config, + stream_mode=["updates", "messages"], + ): + if event[0] == "updates": + for node_data in event[1].values(): + if len(node_data.get("messages", [])) > 0: + yield from output_to_responses_items_stream(node_data["messages"]) + elif event[0] == "messages": + try: + chunk = event[1][0] + if isinstance(chunk, AIMessageChunk) and chunk.content: + yield ResponsesAgentStreamEvent( + **self.create_text_delta(delta=chunk.content, item_id=chunk.id), + ) + except Exception as exc: + logger.error("Error streaming chunk: %s", exc) + + logger.info(f"Finished message stream! thread_id: {thread_id}") + + +# ----- Validate configuration ----- +if not LAKEBASE_INSTANCE_NAME: + raise ValueError( + "LAKEBASE_INSTANCE_NAME is not set. Please set it in the agent.py file or " + "via the LAKEBASE_INSTANCE_NAME environment variable. " + "You can create a Lakebase instance in your Databricks workspace." + ) + + +# ----- First-time checkpoint table setup ----- +def _setup_checkpoint_tables(): + """Initialize Lakebase checkpoint tables if they don't exist. + + This is idempotent - safe to call multiple times. Tables are only + created if they don't already exist. + """ + try: + logger.info(f"Setting up checkpoint tables for Lakebase instance: {LAKEBASE_INSTANCE_NAME}") + with CheckpointSaver(instance_name=LAKEBASE_INSTANCE_NAME) as saver: + saver.setup() + logger.info("✅ Checkpoint tables are ready.") + except Exception as e: + logger.error(f"Failed to setup checkpoint tables: {e}") + raise RuntimeError( + f"Could not initialize Lakebase checkpoint tables for instance '{LAKEBASE_INSTANCE_NAME}'. " + f"Please verify the instance exists and you have proper permissions. Error: {e}" + ) from e + + +# Run setup on module load (first import) +_setup_checkpoint_tables() + + +# ----- Export model ----- +AGENT = LangGraphResponsesAgent(LAKEBASE_INSTANCE_NAME) + +# ----- Register invoke and stream functions with MLflow AgentServer ----- +@invoke() +def handle_invoke(request: ResponsesAgentRequest) -> ResponsesAgentResponse: + """Handle non-streaming invocation requests.""" + return AGENT.predict(request) + + +@stream() +def handle_stream(request: ResponsesAgentRequest) -> Generator[ResponsesAgentStreamEvent, None, None]: + """Handle streaming invocation requests.""" + yield from AGENT.predict_stream(request) + +# ----- Export model ----- +mlflow.models.set_model(AGENT) diff --git a/agent-langgraph-short-term-memory/agent_server/evaluate_agent.py b/agent-langgraph-short-term-memory/agent_server/evaluate_agent.py new file mode 100644 index 00000000..1a38cbc1 --- /dev/null +++ b/agent-langgraph-short-term-memory/agent_server/evaluate_agent.py @@ -0,0 +1,53 @@ +import asyncio + +import mlflow +from dotenv import load_dotenv +from mlflow.genai.agent_server import get_invoke_function +from mlflow.genai.scorers import RelevanceToQuery, Safety +from mlflow.types.responses import ResponsesAgentRequest, ResponsesAgentResponse + +# Load environment variables from .env.local if it exists +load_dotenv(dotenv_path=".env.local", override=True) + +# need to import agent for our @invoke-registered function to be found +from agent_server import agent # noqa: F401 + +# Create your evaluation dataset +# Refer to documentation for evaluations: +# Scorers: https://docs.databricks.com/aws/en/mlflow3/genai/eval-monitor/concepts/scorers +# Predefined LLM scorers: https://mlflow.org/docs/latest/genai/eval-monitor/scorers/llm-judge/predefined +# Defining custom scorers: https://docs.databricks.com/aws/en/mlflow3/genai/eval-monitor/custom-scorers +eval_dataset = [ + { + "inputs": { + "request": { + "input": [{"role": "user", "content": "Calculate the 15th Fibonacci number"}] + } + }, + "expected_response": "The 15th Fibonacci number is 610.", + } +] + +# Get the invoke function that was registered via @invoke decorator in your agent +invoke_fn = get_invoke_function() +assert invoke_fn is not None, ( + "No function registered with the `@invoke` decorator found." + "Ensure you have a function decorated with `@invoke()`." +) + +# if invoke function is async, then we need to wrap it in a sync function +if asyncio.iscoroutinefunction(invoke_fn): + + def sync_invoke_fn(request: dict) -> ResponsesAgentResponse: + req = ResponsesAgentRequest(**request) + return asyncio.run(invoke_fn(req)) +else: + sync_invoke_fn = invoke_fn + + +def evaluate(): + mlflow.genai.evaluate( + data=eval_dataset, + predict_fn=sync_invoke_fn, + scorers=[RelevanceToQuery(), Safety()], + ) diff --git a/agent-langgraph-short-term-memory/agent_server/start_server.py b/agent-langgraph-short-term-memory/agent_server/start_server.py new file mode 100644 index 00000000..4e85a79c --- /dev/null +++ b/agent-langgraph-short-term-memory/agent_server/start_server.py @@ -0,0 +1,50 @@ +import os + +import httpx +from dotenv import load_dotenv +from fastapi import Request, Response +from mlflow.genai.agent_server import AgentServer, setup_mlflow_git_based_version_tracking + +# Load env vars from .env.local before importing the agent for proper auth +load_dotenv(dotenv_path=".env.local", override=True) + +# Need to import the agent to register the functions with the server +import agent_server.agent # noqa: E402 + +agent_server = AgentServer("ResponsesAgent") +# Define the app as a module level variable to enable multiple workers +app = agent_server.app # noqa: F841 +setup_mlflow_git_based_version_tracking() +proxy_client = httpx.AsyncClient(timeout=300.0) + + +@app.middleware("http") +async def proxy_middleware(request: Request, call_next): + for route in app.routes: + if hasattr(route, "path_regex") and route.path_regex.match(request.url.path): + return await call_next(request) + + path = request.url.path.lstrip("/") + try: + body = await request.body() if request.method in ["POST", "PUT", "PATCH"] else None + target_url = f"http://localhost:{os.getenv('CHAT_APP_PORT', '3000')}/{path}" + proxy_response = await proxy_client.request( + method=request.method, + url=target_url, + params=dict(request.query_params), + headers={k: v for k, v in request.headers.items() if k.lower() != "host"}, + content=body, + ) + return Response( + proxy_response.content, + proxy_response.status_code, + headers=dict(proxy_response.headers), + ) + except httpx.ConnectError: + return Response("Service unavailable", status_code=503, media_type="text/plain") + except Exception as e: + return Response(f"Proxy error: {str(e)}", status_code=502, media_type="text/plain") + + +def main(): + agent_server.run(app_import_string="agent_server.start_server:app") diff --git a/agent-langgraph-short-term-memory/agent_server/utils.py b/agent-langgraph-short-term-memory/agent_server/utils.py new file mode 100644 index 00000000..bc6601d7 --- /dev/null +++ b/agent-langgraph-short-term-memory/agent_server/utils.py @@ -0,0 +1,51 @@ +import logging +from typing import Any, AsyncGenerator, AsyncIterator, Optional + +from databricks.sdk import WorkspaceClient +from langchain.messages import AIMessageChunk +from mlflow.genai.agent_server import get_request_headers +from mlflow.types.responses import ( + ResponsesAgentStreamEvent, + create_text_delta, + output_to_responses_items_stream, +) + + +def get_user_workspace_client() -> WorkspaceClient: + token = get_request_headers().get("x-forwarded-access-token") + return WorkspaceClient(token=token, auth_type="pat") + + +def get_databricks_host_from_env() -> Optional[str]: + try: + w = WorkspaceClient() + return w.config.host + except Exception as e: + logging.exception(f"Error getting databricks host from env: {e}") + return None + + +async def process_agent_astream_events( + async_stream: AsyncIterator[Any], +) -> AsyncGenerator[ResponsesAgentStreamEvent, None]: + """ + Generic helper to process agent stream events and yield ResponsesAgentStreamEvent objects. + + Args: + async_stream: The async iterator from agent.astream() + """ + async for event in async_stream: + if event[0] == "updates": + for node_data in event[1].values(): + if len(node_data.get("messages", [])) > 0: + for item in output_to_responses_items_stream(node_data["messages"]): + yield item + elif event[0] == "messages": + try: + chunk = event[1][0] + if isinstance(chunk, AIMessageChunk) and (content := chunk.content): + yield ResponsesAgentStreamEvent( + **create_text_delta(delta=content, item_id=chunk.id) + ) + except Exception as e: + logging.exception(f"Error processing agent stream event: {e}") diff --git a/agent-langgraph-short-term-memory/app.yaml b/agent-langgraph-short-term-memory/app.yaml new file mode 100644 index 00000000..2b94faef --- /dev/null +++ b/agent-langgraph-short-term-memory/app.yaml @@ -0,0 +1,12 @@ +command: ["/bin/bash", "./scripts/start-app.sh"] +# databricks apps listen by default on port 8000 + +env: + - name: MLFLOW_TRACKING_URI + value: "databricks" + - name: MLFLOW_REGISTRY_URI + value: "databricks-uc" + - name: API_PROXY + value: "http://localhost:8000/invocations" + - name: CHAT_APP_PORT + value: "3000" diff --git a/agent-langgraph-short-term-memory/pyproject.toml b/agent-langgraph-short-term-memory/pyproject.toml new file mode 100644 index 00000000..829f4818 --- /dev/null +++ b/agent-langgraph-short-term-memory/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "agent-server" +version = "0.1.0" +description = "MLflow-compatible agent server with short-term memory via Lakebase" +readme = "README.md" +authors = [ + { name = "Agent Developer", email = "developer@example.com" } +] +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.12", + "uvicorn>=0.34.2", + "databricks-langchain[memory]>=0.11.0", + "mlflow>=3.6.0", + "langgraph>=1.0.1", + "python-dotenv", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "hatchling>=1.27.0", + "pytest>=7.0.0", +] + + +[project.scripts] +start-server = "agent_server.start_server:main" +agent-evaluate = "agent_server.evaluate_agent:evaluate" + diff --git a/agent-langgraph-short-term-memory/requirements.txt b/agent-langgraph-short-term-memory/requirements.txt new file mode 100644 index 00000000..60cc5e6a --- /dev/null +++ b/agent-langgraph-short-term-memory/requirements.txt @@ -0,0 +1 @@ +uv diff --git a/agent-langgraph-short-term-memory/scripts/quickstart.sh b/agent-langgraph-short-term-memory/scripts/quickstart.sh new file mode 100755 index 00000000..d6c263a9 --- /dev/null +++ b/agent-langgraph-short-term-memory/scripts/quickstart.sh @@ -0,0 +1,387 @@ +#!/bin/bash +set -e + +# Helper function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Helper function to check if Homebrew is available +has_brew() { + command_exists brew +} + +echo "===================================================================" +echo "Agent on Apps - Quickstart Setup" +echo "===================================================================" +echo + +# =================================================================== +# Section 1: Prerequisites Installation +# =================================================================== + +echo "Checking and installing prerequisites..." +echo + +# Check and install UV +if command_exists uv; then + echo "✓ UV is already installed" + uv --version +else + echo "Installing UV..." + if has_brew; then + echo "Using Homebrew to install UV..." + brew install uv + else + echo "Using curl to install UV..." + curl -LsSf https://astral.sh/uv/install.sh | sh + # Add UV to PATH for current session + export PATH="$HOME/.cargo/bin:$PATH" + fi + echo "✓ UV installed successfully" +fi + +# Check and install nvm +if [ -s "$HOME/.nvm/nvm.sh" ]; then + echo "✓ nvm is already installed" + # Load nvm for current session + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +else + echo "Installing nvm..." + if has_brew; then + echo "Using Homebrew to install nvm..." + brew install nvm + # Create nvm directory + mkdir -p ~/.nvm + # Add nvm to current session + export NVM_DIR="$HOME/.nvm" + [ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh" + [ -s "/usr/local/opt/nvm/nvm.sh" ] && \. "/usr/local/opt/nvm/nvm.sh" + else + echo "Using curl to install nvm..." + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash + # Load nvm for current session + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + fi + echo "✓ nvm installed successfully" +fi + +# Use Node 20 +echo "Setting up Node.js 20..." +nvm install 20 +nvm use 20 +echo "✓ Node.js 20 is now active" +node --version +npm --version +echo + +# Check and install Databricks CLI +if command_exists databricks; then + echo "✓ Databricks CLI is already installed" + databricks --version +else + echo "Installing Databricks CLI..." + if has_brew; then + echo "Using Homebrew to install Databricks CLI..." + brew tap databricks/tap + brew install databricks + else + echo "Using curl to install Databricks CLI..." + if curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sh; then + echo "✓ Databricks CLI installed successfully" + else + echo "Installation failed, trying with sudo..." + curl -fsSL https://raw.githubusercontent.com/databricks/setup-cli/main/install.sh | sudo sh + fi + fi + echo "✓ Databricks CLI installed successfully" +fi +echo + +# =================================================================== +# Section 2: Configuration Files Setup +# =================================================================== +echo "Setting up configuration files..." + +# Copy .env.example to .env.local if it doesn't exist +if [ ! -f ".env.local" ]; then + echo "Copying .env.example to .env.local..." + cp .env.example .env.local + echo +else + echo ".env.local already exists, skipping copy..." +fi +echo + +# =================================================================== +# Section 3: Databricks Authentication +# =================================================================== + +echo "Setting up Databricks authentication..." + +# Check if there are existing profiles +set +e +EXISTING_PROFILES=$(databricks auth profiles 2>/dev/null) +PROFILES_EXIT_CODE=$? +set -e + +if [ $PROFILES_EXIT_CODE -eq 0 ] && [ -n "$EXISTING_PROFILES" ]; then + # Profiles exist - let user select one + echo "Found existing Databricks profiles:" + echo + + # Parse profiles into an array (compatible with older bash) + # Skip the first line (header row) + PROFILE_ARRAY=() + PROFILE_NAMES=() + LINE_NUM=0 + while IFS= read -r line; do + if [ -n "$line" ]; then + if [ $LINE_NUM -eq 0 ]; then + # Print header without number + echo "$line" + else + # Add full line to display array + PROFILE_ARRAY+=("$line") + # Extract just the profile name (first column) for selection + PROFILE_NAME_ONLY=$(echo "$line" | awk '{print $1}') + PROFILE_NAMES+=("$PROFILE_NAME_ONLY") + fi + LINE_NUM=$((LINE_NUM + 1)) + fi + done <<< "$EXISTING_PROFILES" + echo + + # Display numbered list + for i in "${!PROFILE_ARRAY[@]}"; do + echo "$((i+1))) ${PROFILE_ARRAY[$i]}" + done + echo + + echo "Enter the number of the profile you want to use:" + read -r PROFILE_CHOICE + + if [ -z "$PROFILE_CHOICE" ]; then + echo "Error: Profile selection is required" + exit 1 + fi + + # Validate the choice is a number + if ! [[ "$PROFILE_CHOICE" =~ ^[0-9]+$ ]]; then + echo "Error: Please enter a valid number" + exit 1 + fi + + # Convert to array index (subtract 1) + PROFILE_INDEX=$((PROFILE_CHOICE - 1)) + + # Check if the index is valid + if [ $PROFILE_INDEX -lt 0 ] || [ $PROFILE_INDEX -ge ${#PROFILE_NAMES[@]} ]; then + echo "Error: Invalid selection. Please choose a number between 1 and ${#PROFILE_NAMES[@]}" + exit 1 + fi + + # Get the selected profile name (just the name, not the full line) + PROFILE_NAME="${PROFILE_NAMES[$PROFILE_INDEX]}" + echo "Selected profile: $PROFILE_NAME" + + # Test if the profile works + set +e + DATABRICKS_CONFIG_PROFILE="$PROFILE_NAME" databricks current-user me >/dev/null 2>&1 + PROFILE_TEST=$? + set -e + + if [ $PROFILE_TEST -eq 0 ]; then + echo "✓ Successfully validated profile '$PROFILE_NAME'" + else + # Profile exists but isn't authenticated - prompt to authenticate + echo "Profile '$PROFILE_NAME' is not authenticated." + echo "Authenticating profile '$PROFILE_NAME'..." + echo "You will be prompted to log in to Databricks in your browser." + echo + + # Temporarily disable exit on error for the auth command + set +e + + # Run auth login with the profile name and capture output while still showing it to the user + AUTH_LOG=$(mktemp) + databricks auth login --profile "$PROFILE_NAME" 2>&1 | tee "$AUTH_LOG" + AUTH_EXIT_CODE=$? + + set -e + + if [ $AUTH_EXIT_CODE -eq 0 ]; then + echo "✓ Successfully authenticated profile '$PROFILE_NAME'" + # Clean up temp file + rm -f "$AUTH_LOG" + else + # Clean up temp file + rm -f "$AUTH_LOG" + echo "Error: Profile '$PROFILE_NAME' authentication failed" + exit 1 + fi + fi + + # Update .env.local with the profile name + if grep -q "DATABRICKS_CONFIG_PROFILE=" .env.local; then + sed -i '' "s/DATABRICKS_CONFIG_PROFILE=.*/DATABRICKS_CONFIG_PROFILE=$PROFILE_NAME/" .env.local + else + echo "DATABRICKS_CONFIG_PROFILE=$PROFILE_NAME" >> .env.local + fi + echo "✓ Databricks profile '$PROFILE_NAME' saved to .env.local" +else + # No profiles exist - create default one + echo "No existing profiles found. Setting up Databricks authentication..." + echo "Please enter your Databricks host URL (e.g., https://your-workspace.cloud.databricks.com):" + read -r DATABRICKS_HOST + + if [ -z "$DATABRICKS_HOST" ]; then + echo "Error: Databricks host is required" + exit 1 + fi + + echo "Authenticating with Databricks..." + echo "You will be prompted to log in to Databricks in your browser." + echo + + # Temporarily disable exit on error for the auth command + set +e + + # Run auth login with host parameter and capture output while still showing it to the user + AUTH_LOG=$(mktemp) + databricks auth login --host "$DATABRICKS_HOST" 2>&1 | tee "$AUTH_LOG" + AUTH_EXIT_CODE=$? + + set -e + + if [ $AUTH_EXIT_CODE -eq 0 ]; then + echo "✓ Successfully authenticated with Databricks" + + # Extract profile name from the captured output + # Expected format: "Profile DEFAULT was successfully saved" + PROFILE_NAME=$(grep -i "Profile .* was successfully saved" "$AUTH_LOG" | sed -E 's/.*Profile ([^ ]+) was successfully saved.*/\1/' | head -1) + + # Clean up temp file + rm -f "$AUTH_LOG" + + # If we couldn't extract the profile name, default to "DEFAULT" + if [ -z "$PROFILE_NAME" ]; then + PROFILE_NAME="DEFAULT" + echo "Note: Could not detect profile name, using 'DEFAULT'" + fi + + # Update .env.local with the profile name + if grep -q "DATABRICKS_CONFIG_PROFILE=" .env.local; then + sed -i '' "s/DATABRICKS_CONFIG_PROFILE=.*/DATABRICKS_CONFIG_PROFILE=$PROFILE_NAME/" .env.local + else + echo "DATABRICKS_CONFIG_PROFILE=$PROFILE_NAME" >> .env.local + fi + + echo "✓ Databricks profile '$PROFILE_NAME' saved to .env.local" + else + # Clean up temp file + rm -f "$AUTH_LOG" + echo "Databricks authentication was cancelled or failed." + echo "Please run this script again when you're ready to authenticate." + exit 1 + fi +fi +echo + +# =================================================================== +# Section 4: MLflow Experiment Setup +# =================================================================== + + +# Get current Databricks username +echo "Getting Databricks username..." +DATABRICKS_USERNAME=$(databricks -p $PROFILE_NAME current-user me | jq -r .userName) +echo "Username: $DATABRICKS_USERNAME" +echo + +# Create MLflow experiment and capture the experiment ID +echo "Creating MLflow experiment..." +EXPERIMENT_NAME="/Users/$DATABRICKS_USERNAME/agents-on-apps" + +# Try to create the experiment with the default name first +if EXPERIMENT_RESPONSE=$(databricks -p $PROFILE_NAME experiments create-experiment $EXPERIMENT_NAME 2>/dev/null); then + EXPERIMENT_ID=$(echo $EXPERIMENT_RESPONSE | jq -r .experiment_id) + echo "Created experiment '$EXPERIMENT_NAME' with ID: $EXPERIMENT_ID" +else + echo "Experiment name already exists, creating with random suffix..." + RANDOM_SUFFIX=$(openssl rand -hex 4) + EXPERIMENT_NAME="/Users/$DATABRICKS_USERNAME/agents-on-apps-$RANDOM_SUFFIX" + EXPERIMENT_RESPONSE=$(databricks -p $PROFILE_NAME experiments create-experiment $EXPERIMENT_NAME) + EXPERIMENT_ID=$(echo $EXPERIMENT_RESPONSE | jq -r .experiment_id) + echo "Created experiment '$EXPERIMENT_NAME' with ID: $EXPERIMENT_ID" +fi +echo + +# Update .env.local with the experiment ID +echo "Updating .env.local with experiment ID..." +sed -i '' "s/MLFLOW_EXPERIMENT_ID=.*/MLFLOW_EXPERIMENT_ID=$EXPERIMENT_ID/" .env.local +echo + +# Update app.yaml with the experiment ID only if MLFLOW_EXPERIMENT_ID doesn't exist +if grep -q "name: MLFLOW_EXPERIMENT_ID" app.yaml; then + echo "MLFLOW_EXPERIMENT_ID already exists in app.yaml, skipping update..." +else + echo "Adding MLFLOW_EXPERIMENT_ID to app.yaml..." + # Add the two lines to the env section + sed -i '' "/^env:/a\\ + - name: MLFLOW_EXPERIMENT_ID\\ + value: \"$EXPERIMENT_ID\" +" app.yaml + echo "✓ Added MLFLOW_EXPERIMENT_ID to app.yaml" +fi +echo + +# =================================================================== +# Section 5: Lakebase Instance Setup +# =================================================================== + +echo "Setting up Lakebase instance..." +echo "Please enter your Lakebase instance name:" +read -r LAKEBASE_INSTANCE_NAME + +if [ -z "$LAKEBASE_INSTANCE_NAME" ]; then + echo "Error: Lakebase instance name is required" + exit 1 +fi + +# Update .env.local with the Lakebase instance name +if grep -q "LAKEBASE_INSTANCE_NAME=" .env.local; then + sed -i '' "s/LAKEBASE_INSTANCE_NAME=.*/LAKEBASE_INSTANCE_NAME=$LAKEBASE_INSTANCE_NAME/" .env.local +else + echo "LAKEBASE_INSTANCE_NAME=$LAKEBASE_INSTANCE_NAME" >> .env.local +fi +echo "✓ Lakebase instance name saved to .env.local" + +# Update app.yaml with the Lakebase instance name only if it doesn't exist +if grep -q "name: LAKEBASE_INSTANCE_NAME" app.yaml; then + echo "LAKEBASE_INSTANCE_NAME already exists in app.yaml, skipping update..." +else + echo "Adding LAKEBASE_INSTANCE_NAME to app.yaml..." + # Add the two lines to the env section + sed -i '' "/^env:/a\\ + - name: LAKEBASE_INSTANCE_NAME\\ + value: \"$LAKEBASE_INSTANCE_NAME\" +" app.yaml + echo "✓ Added LAKEBASE_INSTANCE_NAME to app.yaml" +fi +echo + +echo "===================================================================" +echo "Setup Complete!" +echo "===================================================================" +echo "✓ Prerequisites installed (UV, nvm, Databricks CLI)" +echo "✓ Databricks authenticated with profile: $PROFILE_NAME" +echo "✓ Configuration files created (.env.local)" +echo "✓ MLflow experiment created: $EXPERIMENT_NAME" +echo "✓ Experiment ID: $EXPERIMENT_ID" +echo "✓ Lakebase instance configured: $LAKEBASE_INSTANCE_NAME" +echo "✓ Configuration updated in .env.local and app.yaml" +echo "===================================================================" +echo diff --git a/agent-langgraph-short-term-memory/scripts/start-app.sh b/agent-langgraph-short-term-memory/scripts/start-app.sh new file mode 100755 index 00000000..87c1e6c5 --- /dev/null +++ b/agent-langgraph-short-term-memory/scripts/start-app.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +# Load environment variables from .env.local if it exists +if [ -f ".env.local" ]; then + echo "Loading environment variables from .env.local..." + export $(cat .env.local | grep -v '^#' | xargs) +fi + +# Create FIFO early for process exit detection +FIFO=$(mktemp -u) +mkfifo "$FIFO" + +# Start backend in background - writes to FIFO on exit +echo "Starting backend..." +{ uv run start-server 2>&1 | tee backend.log; echo "backend ${PIPESTATUS[0]}" > "$FIFO"; } & +BACKEND_PID=$! + +# Check if e2e-chatbot-app-next exists, if not clone it +if [ ! -d "e2e-chatbot-app-next" ]; then + echo "Cloning e2e-chatbot-app-next..." + + # Try HTTPS first, then SSH as fallback + if git clone --filter=blob:none --sparse https://github.com/databricks/app-templates.git temp-app-templates 2>/dev/null; then + echo "Cloned using HTTPS" + elif git clone --filter=blob:none --sparse git@github.com:databricks/app-templates.git temp-app-templates 2>/dev/null; then + echo "Cloned using SSH" + else + echo "ERROR: Failed to clone repository." + echo "Please manually download the folder by going to the following link:" + echo " https://download-directory.github.io/?url=https://github.com/databricks/app-templates/tree/main/e2e-chatbot-app-next" + echo "Then unzip it in this directory and re-run \`./scripts/start-app.sh\`." + exit 1 + fi + + cd temp-app-templates + git sparse-checkout set e2e-chatbot-app-next + cd .. + mv temp-app-templates/e2e-chatbot-app-next . + rm -rf temp-app-templates +fi + +# Start frontend in background - writes to FIFO on exit +echo "Starting frontend..." +cd e2e-chatbot-app-next +npm install +npm run build +{ npm run start 2>&1 | tee ../frontend.log; echo "frontend ${PIPESTATUS[0]}" > "$FIFO"; } & +FRONTEND_PID=$! +cd .. + +# Function to cleanup processes on script exit +cleanup() { + echo "" + echo "==========================================" + echo "Shutting down both processes..." + echo "==========================================" + # Kill child processes first, then the wrapper subshells + pkill -P $BACKEND_PID 2>/dev/null + pkill -P $FRONTEND_PID 2>/dev/null + kill $BACKEND_PID $FRONTEND_PID 2>/dev/null + wait $BACKEND_PID $FRONTEND_PID 2>/dev/null + rm -f "$FIFO" 2>/dev/null +} + +# Function to print error logs +print_error_logs() { + echo "" + echo "Last 50 lines of backend.log:" + echo "----------------------------------------" + tail -50 backend.log 2>/dev/null || echo "(no backend.log found)" + echo "----------------------------------------" + echo "" + echo "Last 50 lines of frontend.log:" + echo "----------------------------------------" + tail -50 frontend.log 2>/dev/null || echo "(no frontend.log found)" + echo "----------------------------------------" +} + +# Trap cleanup function on script termination +trap cleanup SIGINT + +# Monitor both processes +echo "" +echo "Both processes started. Monitoring for failures..." +echo "Backend PID: $BACKEND_PID" +echo "Frontend PID: $FRONTEND_PID" +echo "" + +# Block until first process exits (reads from FIFO) +read RESULT < "$FIFO" +rm -f "$FIFO" + +FAILED=$(echo "$RESULT" | cut -d' ' -f1) +EXIT_CODE=$(echo "$RESULT" | cut -d' ' -f2) + +echo "" +echo "==========================================" +echo "ERROR: $FAILED process exited with code $EXIT_CODE" +echo "==========================================" +print_error_logs +cleanup +exit $EXIT_CODE +