Skip to content

[WIP] [#521] Research Agent Tools - #524

Draft
sahilds1 wants to merge 11 commits into
CodeForPhilly:developfrom
sahilds1:521-research-agent-tools
Draft

[WIP] [#521] Research Agent Tools#524
sahilds1 wants to merge 11 commits into
CodeForPhilly:developfrom
sahilds1:521-research-agent-tools

Conversation

@sahilds1

@sahilds1 sahilds1 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds a second tool to the assistant and makes the eval able to show which tool the
model chose
— the thing this branch exists to change.

  • New ask_database tool — read-only SQL over the medication table, reusing the
    existing function in api/services/tools/database.py (SELECT-only + ALLOWED_TABLES
    guards not reimplemented). Schema built in the flattened Responses-API shape, which
    differs from Chat Completions' nested one.
  • One Tool dataclass + one TOOLS list — replaces two parallel registries
    (get_tools_schema / make_tool_mapping) linked only by a duplicated, unenforced
    tool-name string, where a half-registered tool failed at runtime. user is now bound
    at dispatch time, which deletes the binder/closure machinery.
  • No DB query at import time — the schema string is a hardcoded constant (4-column,
    effectively frozen table). Also drops the AppRegistryNotReady risk and lets us hide
    id from the model.
  • run_assistant returns AssistantResult (output_text, response_id,
    tool_calls) instead of a tuple, carrying a ToolCall per call. status is a
    3-state enum, not a bool: a tool that raised and a tool name the model
    hallucinated are opposite diagnoses. Dataclass over widened tuple so later metrics
    are defaulted fields with zero call-site churn.
  • Closes a swallowed-failure hole — tool exceptions are caught and fed back to the
    model as a string, so it writes fluent prose around a failed retrieval. Previously a
    run where ask_database threw on every question read as five clean rows; now it shows
    as tool_error_count > 0 while error is None.
  • Eval CSV gains response_id, tools_called, tool_call_count,
    tool_error_count, tool_calls_json, duration_s.
  • Housekeeping — 10 relative imports → absolute; search_tool.py split out (with
    the imports that split had left broken); one shared MODEL_NAME constant so the CSV
    can't mislabel the model.

API unchangedviews.py unpacks the dataclass; the JSON body is byte-identical.
tool_calls are intentionally not exposed to the frontend.

Related Issue

#521

Manual Tests

# unit suite (no API key, no fixtures)
docker compose exec backend python manage.py test api.views.assistant -v2

# eval CSV — needs OPENAI_API_KEY, a superuser, embedded docs
EVAL_BRANCH=521-research-agent-tools \
  docker compose exec backend python api/views/assistant/eval_assistant.py

# response shape unchanged (the refactor's regression check)
curl -X POST http://localhost:8000/v1/api/assistant \
  -H "Authorization: JWT <token>" -H "Content-Type: application/json" \
  -d '{"message": "What medications are recommended for bipolar depression?"}'
# expect: {"response_output_text": "...", "final_response_id": "resp_..."}

Automated Tests

19 unit tests, OpenAI + DB + tools all mocked — no live calls, no fixtures.

File # Covers
test_tool_services.py 13 Tool.run/schema()/TOOLS; a ToolCall recorded on all three dispatch branches (OK / FAILED / UNREGISTERED); loop termination, previous_response_id continuity, tool calls accumulating across iterations
test_assistant_services.py 4 Input shape, include/omit previous_response_id, and that TOOLS + user are forwarded to the loop
test_eval_assistant.py 2 A raising question becomes an error row, not an aborted batch; the swallowed-failure case (tool_error_count == 1, error is None)

Deliberately uncovered: search_documents' own paths (TODO in-file — mockable, no DB
needed) and main()'s CSV writing.

Documentation

No user-facing or CLAUDE.md changes — internal to the assistant module, HTTP contract
unchanged. Design rationale is documented inline where the code lives (why behavior is a
run field not a subclass method; why the schema is hardcoded; why tests patch use
sites, with a maintenance guard).

Reviewers

Notes

Targets develop.

Before merge

  • ⚠️ Suite not run since the last commit; the eval has never run end-to-end, so a
    green suite is not evidence a CSV comes out. First run also tests the self-flagged
    sys.path caveat in eval_assistant.py ("../../../../"/usr/src, not
    /usr/src/server).
  • ⚠️ Dropping id from the schema string is a behavior change, not a pure refactor —
    worth confirming no prompt or eval expected it.

Known, documented rather than fixed

  • If client.responses.create raises mid-loop, every ToolCall so far is lost with the
    exception (tool_call_count 0 despite real calls). Fixing it means deciding whether
    AssistantResult describes a successful run or whatever happened — a contract
    change.
  • Tool overlap: semantic search and SQL can both answer the same question. The new
    columns exist to make that observable; sharper descriptions may be needed.
  • Keep key order identical in both run_one row literals — as_completed is
    nondeterministic, so that's the only thing keeping CSV column order stable.

Deferred, design written down

  • Token usage + turn count as defaulted fields (incl. cached_tokens — every turn
    resends context, so cost from input_tokens alone overstates spend).
  • INSTRUCTIONS{branch}-{timestamp}.prompt.txt sidecar beside the CSV.
  • Scoring layer — the biggest gap. eval_assistant.py is still a generation
    harness: no ground truth, so the CSV shows what was said and which tools ran, but not
    whether the answer was right. Citation accuracy is the cheapest real signal (the
    mandated [Name, Page] format can be checked against what search_documents returned).

@sahilds1 sahilds1 self-assigned this Jul 15, 2026
@sahilds1 sahilds1 changed the title Research Agent Tools [WIP] [#521] Research Agent Tools Jul 15, 2026
@sahilds1
sahilds1 marked this pull request as draft July 15, 2026 22:50
sahilds1 added 10 commits July 16, 2026 15:10
Add ask_database as a second tool for the assistant, alongside the
existing semantic search_documents tool.

- Reuse the SELECT-only, ALLOWED_TABLES-guarded ask_database
  implementation from services/tools/database.py rather than
  reimplementing the query guards in the assistant.
- Add get_tools_schema() / make_tool_mapping() as an aggregation seam
  in tool_services.py so assistant_services.py no longer names
  individual tools; new tools are registered in one place.
- Build the ask_database schema in the flattened Responses-API shape
  (not the nested Chat Completions shape from services/tools), and
  defer the database_schema_string import to call time so importing
  the module never triggers a DB query.
- Split tool_services.py: move search_documents into search_tool.py and
  the agentic-loop helpers (handle_tool_calls_with_reasoning,
  invoke_functions_from_response) into agentic_loop.py, adding the
  imports each module needs.
- Point importers (assistant_services.py, test_tool_services.py) at the
  defining module for each symbol instead of re-exporting through
  tool_services.py.
- Document the search/SQL tool overlap risk and the import-time DB
  access caveat inline.
Replace the two parallel tool registries (get_tools_schema /
make_tool_mapping) with a single Tool dataclass and a TOOLS list, so each
tool's schema and callable live under one name and can't drift apart.

- Add Tool(name, description, parameters, run) with a .schema() method;
  define SEARCH_TOOL and ASK_DATABASE_TOOL instances and a single TOOLS
  list as the source of truth. Adding a tool is appending one Tool.
- Build the ask_database schema from Medication._meta (concrete_fields /
  db_table) instead of introspecting the live database, removing the
  import-time DB query and the deferred database_schema_string import.
- Bind the request user at dispatch time: invoke_functions_from_response
  and handle_tool_calls_with_reasoning now take (tools, user), index
  tools by name, and call tool.run(user=user, **arguments). This drops
  make_tool_mapping / make_search_tool_mapping and their closures.
- assistant_services builds the schema list with
  [tool.schema() for tool in TOOLS] and forwards TOOLS + user to the loop.
- Update tests to cover the Tool instances and pass (tools, user) to the
  loop; the tool_services.search_documents / ask_database patch paths
  still resolve.
Convert every relative import in api/views/assistant to its absolute
equivalent (assistant_services, search_tool, tool_services, urls, views).
Multi-dot forms like `...services.tools.database` and `..listMeds.models`
were error-prone to read and would break silently if a file's package
depth changed; the absolute paths are move-safe and unambiguous.

Expand the tool_services.py import comments to record why search_documents
and ask_database are imported as bare names: the tests patch them at their
use site (api.views.assistant.tool_services.<name>), not their definition
site, so mock.patch rebinds the reference SEARCH_TOOL.run actually resolves
at call time. Note the maintenance guard — qualifying those calls would
move the patch target and break the tests.

Move the _medication_schema_string helper to sit directly above its only
caller, ASK_DATABASE_TOOL, instead of above SEARCH_TOOL.

No behavior change: all bound names and patch targets are preserved.
Replace the _medication_schema_string() helper (which read columns from
Medication._meta) with a hand-written _MEDICATION_SCHEMA_STRING constant,
and drop the now-unused Medication import. The _meta approach auto-synced
with the model but dumped every column and pulled in an app-registry
dependency (AppRegistryNotReady if imported during app startup). For a
4-column, stable table, a curated constant is simpler, lets us hide
columns from the LLM (omit `id`, which it never filters on), and removes
the startup coupling — at the cost of a one-line manual update if the
table's columns ever change, which the comment calls out.

Note: this drops `id` from the schema the model sees (intentional
curation, not just a port of the old behavior).

Also document in the Tool docstring why behavior is a `run` field
(composition) rather than a subclass method: the tools differ only in
which function runs, so they are instances of one concept, not distinct
types. Add a TODO listing the signals that would justify flipping to
Tool(ABC) + per-tool subclasses (per-type state, overriding more than
run, or a per-type/abstractmethod-enforced contract).
The eval showed what the assistant said but not how it chose tools. Tool-call
info was produced in invoke_functions_from_response and dropped at every return
boundary; caught tool exceptions were fed back to the model as strings, so a run
where ask_database threw every question read as clean rows.

Carry it up as return values (over a mutable out-param: honest domain data that
grows via defaulted fields, no call-site churn):

- agentic_loop: add ToolCallStatus (OK/FAILED/UNREGISTERED — a bool was both
  redundant with error and lossy), ToolCall(name, status, arguments, output,
  error), and AssistantResult(output_text, response_id, tool_calls).
  invoke_functions_from_response returns (messages, tool_calls); the loop
  accumulates across iterations and returns AssistantResult.
- assistant_services: return AssistantResult (pass-through). Drops the TODO.
- views: read result fields; JSON body unchanged.
- eval_assistant: run_one times the call locally and adds tools_called,
  tool_call_count, tool_error_count, tool_calls_json, response_id, duration_s —
  making tool_error_count > 0 while error is None visible.

Deferred: token-cost, turn count, correctness scoring. Tests updated.
Hoist MODEL_NAME into assistant_services, import it in eval_assistant, and drop
the duplicated literal — the only functional change here.

The rest is comments. Token usage and turn count, the scoring layer, and the
INSTRUCTIONS sidecar were each designed in this pass and deliberately not built;
the TODOs sit where the work will happen and carry the reasoning.
The eval had never been run. Every test mocks run_assistant, so a green
suite proved run_one's row shaping but never that a CSV came out.

Three defects stopped it running at all: the sys.path depth, pandas
missing from the backend image, and the uv shebang and PEP 723 header,
which never described a runnable configuration.

A fourth is different in kind, and only the run could surface it. The
cold-start race on the embedding model does not stop the eval — it
corrupts it, completing normally and writing a CSV that looks clean.
Warming the model before the pool addresses it here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant