Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions server/api/views/assistant/agentic_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import json
import logging

logger = logging.getLogger(__name__)


def handle_tool_calls_with_reasoning(
response, client, model_defaults: dict, tools: list, user
) -> tuple[str, str]:
"""Run the agentic loop until the model stops emitting function calls.

Parameters
----------
response : OpenAI Response
The initial response from the model.
client : OpenAI
The OpenAI client instance.
model_defaults : dict
Keyword arguments forwarded to every client.responses.create call.
tools : list[Tool]
The available tools; each has a `.name` and a `.run(user, **arguments)`.
user : User
The request user, bound into each tool call at dispatch time.

Returns
-------
tuple[str, str]
(final_response_output_text, final_response_id)
"""
# Open AI Cookbook: Handling Function Calls with Reasoning Models
# https://cookbook.openai.com/examples/reasoning_function_calls
while True:
function_responses = invoke_functions_from_response(response, tools, user)
if len(function_responses) == 0: # We're done reasoning
logger.info("Reasoning completed")
final_response_output_text = response.output_text
final_response_id = response.id
logger.info(f"Final response: {final_response_output_text}")
return final_response_output_text, final_response_id
else:
logger.info("More reasoning required, continuing...")
response = client.responses.create(
input=function_responses,
previous_response_id=response.id,
**model_defaults,
)


def invoke_functions_from_response(
response, tools: list, user
) -> list[dict]:
"""Extract all function calls from the response, look up the corresponding tool(s) and execute them.
(This would be a good place to handle asynchroneous tool calls, or ones that take a while to execute.)
This returns a list of messages to be added to the conversation history.

Parameters
----------
response : OpenAI Response
The response object from OpenAI containing output items that may include function calls
tools : list[Tool]
The available tools. Indexed by `.name` here to dispatch the model's calls; each
is invoked as `tool.run(user, **arguments)`.
user : User
The request user, forwarded to every tool call so tools that need it (e.g.
document access control) get it, and tools that don't simply ignore it.

Returns
-------
list[dict]
List of function call output messages formatted for the OpenAI conversation.
Each message contains:
- type: "function_call_output"
- call_id: The unique identifier for the function call
- output: The result returned by the executed function (string or error message)
"""

# Open AI Cookbook: Handling Function Calls with Reasoning Models
# https://cookbook.openai.com/examples/reasoning_function_calls

# Index the tools by name so a model-supplied call name can be looked up. .get()
# returns None for an unknown name, handled explicitly below.
tools_by_name = {tool.name: tool for tool in tools}
intermediate_messages = []
for response_item in response.output:
if response_item.type == "function_call":
target_tool = tools_by_name.get(response_item.name)
if target_tool is not None:
try:
arguments = json.loads(response_item.arguments)
logger.info(
f"Invoking tool: {response_item.name} with arguments: {arguments}"
)
tool_output = target_tool.run(user=user, **arguments)
logger.info(f"Tool {response_item.name} completed successfully")
except Exception as e:
msg = f"Error executing function call: {response_item.name}: {e}"
tool_output = msg
logger.error(msg, exc_info=True)
else:
msg = f"ERROR - No tool registered for function call: {response_item.name}"
tool_output = msg
logger.error(msg)
intermediate_messages.append(
{
"type": "function_call_output",
"call_id": response_item.call_id,
"output": tool_output,
}
)
elif response_item.type == "reasoning":
logger.info(f"Reasoning step: {response_item.summary}")
return intermediate_messages
24 changes: 10 additions & 14 deletions server/api/views/assistant/assistant_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,9 @@

from openai import OpenAI

from .assistant_prompts import INSTRUCTIONS
from .tool_services import (
SEARCH_TOOLS_SCHEMA,
make_search_tool_mapping,
handle_tool_calls_with_reasoning,
)
from api.views.assistant.assistant_prompts import INSTRUCTIONS
from api.views.assistant.tool_services import TOOLS
from api.views.assistant.agentic_loop import handle_tool_calls_with_reasoning

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -44,15 +41,12 @@ def run_assistant(
"model": "gpt-5-nano", # 400,000 token context window
# A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process.
"reasoning": {"effort": "low", "summary": None},
"tools": SEARCH_TOOLS_SCHEMA,
# The model only ever sees each tool's schema (name/description/parameters),
# derived from the single TOOLS list in tool_services.py. The request `user` is
# not part of the schema — it is bound into each call later, at dispatch time.
"tools": [tool.schema() for tool in TOOLS],
}

# TOOLS_SCHEMA tells the model what tools exist and what arguments to generate.
# tool_mapping wires those tool names to the Python functions that execute them.
# They are separate because the model generates arguments (schema concern) but
# cannot supply request-time values like user (mapping concern).
tool_mapping = make_search_tool_mapping(user)

if not previous_response_id:
response = client.responses.create(
input=[
Expand All @@ -69,4 +63,6 @@ def run_assistant(
**MODEL_DEFAULTS,
)

return handle_tool_calls_with_reasoning(response, client, MODEL_DEFAULTS, tool_mapping)
# Pass TOOLS and user through to the loop, which indexes tools by name and binds
# user into each tool call at dispatch time.
return handle_tool_calls_with_reasoning(response, client, MODEL_DEFAULTS, TOOLS, user)
50 changes: 50 additions & 0 deletions server/api/views/assistant/search_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from api.services.embedding_services import get_closest_embeddings
from api.services.conversions_services import convert_uuids


def search_documents(query: str, user) -> str:
"""
Search through user's uploaded documents using semantic similarity.

This function performs vector similarity search against the user's document corpus
and returns formatted results with context information for the LLM to use.

Parameters
----------
query : str
The search query string
user : User
The authenticated user whose documents to search

Returns
-------
str
Formatted search results containing document excerpts with metadata

Raises
------
Exception
If embedding search fails
"""

try:
embeddings_results = get_closest_embeddings(
user=user, message_data=query.strip()
)
embeddings_results = convert_uuids(embeddings_results)

if not embeddings_results:
return "No relevant documents found for your query. Please try different search terms or upload documents first."

# Format results with clear structure and metadata
prompt_texts = [
f"[Document {i + 1} - File: {obj['file_id']}, Name: {obj['name']}, Page: {obj['page_number']}, Chunk: {obj['chunk_number']}, Similarity: {1 - obj['distance']:.3f}]\n{obj['text']}\n[End Document {i + 1}]"
for i, obj in enumerate(embeddings_results)
]

return "\n\n".join(prompt_texts)

except Exception as e:
return f"Error searching documents: {str(e)}. Please try again if the issue persists."


23 changes: 11 additions & 12 deletions server/api/views/assistant/test_assistant_services.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Tests for run_assistant (assistant_services.py): the orchestrator that wires the
# OpenAI client, the search tool mapping, and the agentic loop together.
# OpenAI client, the tool schemas, and the agentic loop together.
#
# The OpenAI client and handle_tool_calls_with_reasoning are mocked, so these
# tests cover only logic run_assistant owns: how it builds the user input message,
# its decision to include vs. omit previous_response_id, and that it binds the
# request user into the search tool. No live OpenAI calls and no database.
# its decision to include vs. omit previous_response_id, and that it forwards the
# TOOLS and the request user to the loop. No live OpenAI calls and no database.

from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -68,24 +68,23 @@ def test_run_assistant_omits_previous_response_id_when_none(mock_openai_cls, moc
assert "previous_response_id" not in call_kwargs


@patch("api.views.assistant.tool_services.search_documents")
@patch("api.views.assistant.assistant_services.handle_tool_calls_with_reasoning")
@patch("api.views.assistant.assistant_services.OpenAI")
def test_run_assistant_binds_user_to_search_documents(mock_openai_cls, mock_handle, mock_search):
def test_run_assistant_forwards_tools_and_user_to_loop(mock_openai_cls, mock_handle):
mock_client = MagicMock()
mock_openai_cls.return_value = mock_client
mock_client.responses.create.return_value = _make_terminal_response()
mock_handle.return_value = ("answer", "resp-1")

from api.views.assistant.assistant_services import run_assistant
from api.views.assistant.tool_services import TOOLS

user = MagicMock()
run_assistant(message="query", user=user)

# Extract the tool_mapping passed to handle_tool_calls_with_reasoning
tool_mapping = mock_handle.call_args.kwargs.get("tool_mapping") or mock_handle.call_args.args[3]
bound_search = tool_mapping["search_documents"]

# Calling the bound function should forward user to search_documents
bound_search(query="test query")
mock_search.assert_called_once_with("test query", user)
# run_assistant no longer binds user itself — it forwards TOOLS and the user to the
# loop, which binds user into each tool call at dispatch time.
# handle_tool_calls_with_reasoning(response, client, model_defaults, tools, user)
args = mock_handle.call_args.args
assert args[3] is TOOLS
assert args[4] is user
Loading
Loading