Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b3669b0
Add TODOs for adding tools
sahilds1 Jul 15, 2026
b5710e9
Add ask_database tool and split tool module
sahilds1 Jul 16, 2026
aba10f8
Refactor model tools as a Tool dataclass, drop the aggregators
sahilds1 Jul 17, 2026
76a78b0
Use absolute imports in assistant module; clarify test-patching contract
sahilds1 Jul 21, 2026
35a360d
Hardcode ask_database schema string; document Tool composition choice
sahilds1 Jul 21, 2026
100ed12
Capture tool calls and duration in the eval CSV
sahilds1 Jul 24, 2026
85a1a0a
Share one MODEL_NAME constant; record deferred eval work as TODOs
sahilds1 Jul 27, 2026
53afce8
Merge branch 'develop' into 521-research-agent-tools
sahilds1 Aug 4, 2026
b6eca89
Unblock the eval's first end-to-end run
sahilds1 Aug 4, 2026
2e58e11
Eval reported clean runs while retreivals crashed because of a race
sahilds1 Aug 4, 2026
f129ce4
Note: b6eca89 bundles the race on the embedding model fixes with the …
sahilds1 Aug 4, 2026
ff75d34
Test only the logic we wrote: drop glue tests, dedupe with parametrize
sahilds1 Aug 7, 2026
408b2a4
Add comments for open follow ups
sahilds1 Aug 11, 2026
a4949cb
Tool, ToolCallStatus, ToolCall and AssistantResult now live in one mo…
sahilds1 Aug 11, 2026
fba44f2
invoke_functions_from_response's function_call branch moves to _execu…
sahilds1 Aug 11, 2026
72582ab
Note: fba44f2 bundles renaming initial_response fix
sahilds1 Aug 11, 2026
fb4ab35
Rename the agentic loop's functions and record types
sahilds1 Aug 16, 2026
daed3de
DOC: Condense comments in assistant
sahilds1 Aug 17, 2026
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
117 changes: 117 additions & 0 deletions server/api/views/assistant/agentic_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import json
import logging

from api.views.assistant.assistant_types import (
AgentResult,
ToolCallExecution,
ToolCallStatus,
)

logger = logging.getLogger(__name__)


def run_agentic_loop(
response, client, model_defaults: dict, tools: list, user
) -> AgentResult:

# Every tool call the agentic loop made before exiting
agentic_loop_tool_call_executions= []

while True:
# user is threaded through so tools that need it get it at dispatch time

tool_output_schemas, tool_call_executions = handle_tool_calls(response, tools, user)

# .extend splices every iteration's list of tools into one list
agentic_loop_tool_call_executions.extend(tool_call_executions)

# Exit agentic loop when model response doesn't contain any tool calls
if not tool_output_schemas:
return AgentResult(
output_text=response.output_text,
response_id=response.id,
tool_calls=agentic_loop_tool_call_executions,
)

#TODO: Add error handling to collect partial AgentResult tool calls
response = client.responses.create(
input=tool_output_schemas,
previous_response_id=response.id,
**model_defaults,
)


def handle_tool_calls(
response, tools: list, user
) -> tuple[list[dict], list[ToolCallExecution]]:

# 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}

tool_output_schemas = []
tool_call_executions: list[ToolCallExecution] = []

for response_item in response.output:
if response_item.type == "reasoning":
#logger.info(f"Reasoning step: {response_item.summary}")

elif response_item.type == "function_call":

tool_output, tool_call_execution = _execute_function_call(response_item, tools_by_name, user)

tool_output_schemas.append(
{
"type": "function_call_output",
"call_id": response_item.call_id,
"output": tool_output,
}
)

tool_call_executions.append(tool_call_execution)


return tool_output_schemas, tool_call_executions


def _execute_function_call(
response_item, tools_by_name: dict, user
) -> tuple[str, ToolCallExecution]:

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, ToolCallExecution(
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, ToolCallExecution(
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, ToolCallExecution(
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
71 changes: 26 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,51 @@

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 AgentResult
from api.views.assistant.agentic_loop import run_agentic_loop

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)
"""
# 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
) -> AgentResult:

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: Flip "summary" to "auto" once this org is confirmed verified with OpenAI
"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)
# search_documents needs the request user for document access control
return run_agentic_loop(initial_response, client, MODEL_DEFAULTS, TOOLS, user)

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

# search_documents needs the request user for document access control
return run_agentic_loop(initial_response, client, MODEL_DEFAULTS, TOOLS, user)
71 changes: 71 additions & 0 deletions server/api/views/assistant/assistant_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from dataclasses import dataclass
from enum import Enum
from typing import Callable

@dataclass(frozen=True)
class Tool:
"""
Instances are registered in tool_services.py's TOOLS list.
"""
name: str
description: str
parameters: dict
# Function we run: run(user, **arguments) -> str.
# Every tool takes the request `user` so the dispatch loop can call them uniformly;
# A tool that doesn't need it simply ignores it.
run: Callable

# Schema that the model sees: Flattened Responses-API shape
def schema(self) -> dict:
return {
"type": "function",
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}


class ToolCallStatus(str, Enum):
"""
Evaluate tool selection and distinguish between FAILED and UNREGISTERED
"""

OK = "ok"
# Tool matched but raised an error
FAILED = "failed"
# No tool registered for the model's requested name:
# The model asked for a tool name we don't have
UNREGISTERED = "unregistered"


@dataclass(frozen=True)
class ToolCallExecution:
"""
A record of one tool call the model made
"""

name: str
# `output` and `error` are disjoint by status
status: ToolCallStatus
# the query the model generated (the primary tool selection signal)
# None only when the model's argument JSON could not be parsed
arguments: dict | None = None
# the tool's result on success (retrieved content)
output: str | None = None
# the failure detail when status is not OK
error: str | None = None


@dataclass(frozen=True)
class AgentResult:
"""
# Built by the agentic loop as a run proceeds,
# and read by eval_assistant.py to fill the result CSV
"""

# The model's final text
output_text: str
# The id of the final response (for multi-turn continuity)
response_id: str
# The ordered ToolCallExecutionrecords for every tool invocation across all loop iterations
tool_calls: list[ToolCallExecution]
Loading
Loading