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
137 changes: 137 additions & 0 deletions server/api/views/assistant/agentic_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import json
import logging

from api.views.assistant.assistant_types import (
AssistantResult,
ToolCall,
ToolCallStatus,
)

logger = logging.getLogger(__name__)


def handle_tool_calls_with_reasoning(
response, client, model_defaults: dict, tools: list, user
) -> AssistantResult:
"""
TODO: Read server/api/views/assistant and fill in the docstring

TODO: Reference the OpenAI Cookbook in the docstring

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

# Every tool call the run made, accumulated across turns: AssistantResult reports
# the whole run, not just the turn that happened to end it.
all_tool_calls: list[ToolCall] = []
while True:
# user is threaded through so tools that need it get it at dispatch time; tools that don't simply ignore it.
tool_output_messages, turn_tool_calls = invoke_functions_from_response(response, tools, user)
all_tool_calls.extend(turn_tool_calls)
if not tool_output_messages: # Model emitted no tool calls this turn
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 AssistantResult(
output_text=final_response_output_text,
response_id=final_response_id,
tool_calls=all_tool_calls,
)
else:
logger.info("More reasoning required, continuing...")
response = client.responses.create(
input=tool_output_messages,
previous_response_id=response.id,
**model_defaults,
)


def invoke_functions_from_response(
response, tools: list, user
) -> tuple[list[dict], list[ToolCall]]:
"""
TODO: Read server/api/views/assistant and fill in the docstring

TODO: Reference the OpenAI Cookbook in the docstring

# 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 = []
tool_calls: list[ToolCall] = []

for response_item in response.output:
if response_item.type == "function_call":
tool_output, tool_call = _execute_function_call(
response_item, tools_by_name, user
)
tool_calls.append(tool_call)
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, tool_calls


def _execute_function_call(
response_item, tools_by_name: dict, user
) -> tuple[str, ToolCall]:
"""Run the one tool the model asked for, on any of its three outcomes.

Returns a pair because the two results have different destinations: the string is
fed back to the model as the function_call_output, while the ToolCall is kept for
the eval and never reaches the model.

They are not derivable from each other, which is the trap in collapsing this to a
single return. On OK the two carry the same text, and on UNREGISTERED they do too —
but on FAILED the model gets a message naming the tool it called, while
ToolCall.error holds the bare exception. Deriving one from the other would quietly
change what the model sees after a tool failure.
"""
target_tool = tools_by_name.get(response_item.name)
# Parsed below; stays None if the model's argument JSON can't be parsed,
# so a FAILED record still reports whatever we managed to read.
arguments = None

if target_tool is None:
msg = f"ERROR - No tool registered for function call: {response_item.name}"
logger.error(msg)
return msg, ToolCall(
name=response_item.name,
status=ToolCallStatus.UNREGISTERED,
error=msg,
)

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")
return tool_output, ToolCall(
name=response_item.name,
status=ToolCallStatus.OK,
arguments=arguments,
output=tool_output,
)
except Exception as e:
msg = f"Error executing function call: {response_item.name}: {e}"
logger.error(msg, exc_info=True)
return msg, ToolCall(
name=response_item.name,
status=ToolCallStatus.FAILED,
arguments=arguments,
error=str(e),
)
20 changes: 20 additions & 0 deletions server/api/views/assistant/assistant_prompts.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# TODO: rewrite the citation template below (RESPONSE FORMAT item 4) so the braces are not
# emitted literally. `[Name {name}, Page {page_number}]` is read by the model as required
# output *syntax* rather than as placeholders: the 20260807 eval returned
# [Pharmacological Treatment of Bipolar Depression: ... Options? {Pharmacological
# Treatment of Bipolar Depression: ... Options?}, Page 2]
# — the name filled in AND the braces kept, duplicating the title. Also observed:
# "Page: 3" (stray colon), "Page 4, Chunk 32" (extra field), "various pages",
# "multiple pages including 1-5". Show a filled-in example instead of a brace template,
# e.g. `[Name advancespharmaco.pdf, Page 9]`, and state that exactly one page number is
# cited per reference.
#
# This is one of two separable citation defects; the other is search_tool.py handing the
# model a UUID alongside the name (see the TODO there). Neither is cosmetic — citations
# are unparseable until both land, which blocks citation accuracy, the "cheapest real
# signal" the scoring TODO in eval_assistant.py is built on.
#
# Note both known importers pass this string through verbatim — assistant_services.py
# hands it to the API as `instructions`, eval_assistant.py imports it for a planned
# sidecar and does not use it — so no .format() reads the braces. They are inert to
# Python; the only thing interpreting them is the model.
INSTRUCTIONS = """
You are an AI assistant that helps users find and understand information about bipolar disorder
from your internal library of bipolar disorder research sources using semantic search.
Expand Down
73 changes: 28 additions & 45 deletions server/api/views/assistant/assistant_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,53 @@

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.assistant_types import AssistantResult
from api.views.assistant.agentic_loop import handle_tool_calls_with_reasoning

logger = logging.getLogger(__name__)

# Module-level so eval_assistant.py can import it and label its CSV with the model that actually ran
MODEL_NAME = "gpt-5-nano"


def run_assistant(
message: str,
user,
message: str,
previous_response_id: str | None = None,
) -> tuple[str, str]:
"""Wire together the OpenAI client, retrieval, and the agentic reasoning loop.

Parameters
----------
message : str
The user's input message.
user : User
The Django user object used for document access control in search_documents.
previous_response_id : str | None
ID of a prior response for multi-turn conversation continuity.

Returns
-------
tuple[str, str]
(final_response_output_text, final_response_id)
) -> AssistantResult:
"""
TODO: Read server/api/views/assistant and fill in the docstring
"""
# TODO: Track total duration, cost metrics, and tool_calls_made count
# and return them from run_assistant for use in eval_assistant.py CSV output

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

MODEL_DEFAULTS = {
"instructions": INSTRUCTIONS,
"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.
"model": MODEL_NAME,
# TODO: Note how the summary can be used for debugging and understanding the model's reasoning process.
"reasoning": {"effort": "low", "summary": None},
"tools": SEARCH_TOOLS_SCHEMA,
"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=[
{"type": "message", "role": "user", "content": str(message)}
],
**MODEL_DEFAULTS,
)
else:
response = client.responses.create(
if previous_response_id:
initial_response = client.responses.create(
input=[
{"type": "message", "role": "user", "content": str(message)}
],
previous_response_id=str(previous_response_id),
**MODEL_DEFAULTS,
)

return handle_tool_calls_with_reasoning(response, client, MODEL_DEFAULTS, tool_mapping)
# TODO: Explain the reason user is not part of the schema and is bound into each call at dispatch time
return handle_tool_calls_with_reasoning(initial_response, client, MODEL_DEFAULTS, TOOLS, user)

initial_response = client.responses.create(
input=[
{"type": "message", "role": "user", "content": str(message)}
],
**MODEL_DEFAULTS,
)

# TODO: Explain the reason user is not part of the schema and is bound into each call at dispatch time
return handle_tool_calls_with_reasoning(initial_response, client, MODEL_DEFAULTS, TOOLS, user)
Loading
Loading