Files
sure/app/models/assistant/function_tool_caller.rb
T
Brandon ce92b36351 feat(bills): assistant and MCP tools for bills (#3203)
* feat(bills): assistant and MCP tools for bills

Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes
bills to the builtin assistant and to MCP clients. Everything here is gated
behind preview features, so the tools are absent from tools/list until a user
opts in.

Seven tools:

- get_bills, get_bill_details and get_paycheck_plan for reads
- get_bill_audit, a deterministic review that surfaces likely duplicates, price
  changes, trials about to convert, upcoming renewals and long-overdue bills
- create_bill, update_bill and record_bill_payment for writes

Shared argument parsing, permission checks and error shapes live in
BillsSupport, so every tool answers with the same {error, hint} contract the
existing tools use, and a bad argument never aborts the turn.

The write tools mutate financial records on a model's say-so, so they refuse
rather than guess: a payment cannot exceed what its cycle still owes, a repeated
settle will not quietly close next month, an unrecognized frequency is an error
instead of a silent monthly default, and non-finite or negative amounts are
rejected before they reach the database.

The read tools say what they filtered. An empty result names the statuses that
do hold matches, the paycheck plan discloses the unconfirmed series it excluded
from spending headroom, and history and price-change windows report their real
totals rather than letting a caller sum a truncated list.

A not-found no longer returns the scoped relation's SQL, which handed any MCP
client the access-control schema for the cost of a guessed id.

The in-page AI helpers are not here. Smart fill and smart configuration are
buttons on the bills pages, so they ship with the UI bundle along with the
provider-side suggester they call.

Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified.

* Address the ready-review round

* Reject an out-of-range audit lookback out loud

* Speak the cycle remainder guard through the allocator locale
2026-09-02 07:06:13 +02:00

79 lines
2.9 KiB
Ruby

class Assistant::FunctionToolCaller
Error = Class.new(StandardError)
FunctionExecutionError = Class.new(Error)
attr_reader :functions
def initialize(functions = [])
@functions = functions
end
def fulfill_requests(function_requests)
function_requests.map do |function_request|
result = execute(function_request)
ToolCall::Function.from_function_request(function_request, result)
end
end
def function_definitions
functions.map(&:to_definition)
end
private
# Tool failures come back as data instead of raising, so one bad call no
# longer aborts the whole turn. The hint steers the model toward a single
# corrected retry (the system prompt pairs it with a retry-once rule).
def execute(function_request)
fn = find_function(function_request)
if fn.nil?
return {
error: "Unknown tool: #{function_request.function_name}",
hint: "Only call tools from the provided list."
}
end
fn_args = JSON.parse(function_request.function_args.presence || "{}")
fn.call(fn_args)
rescue JSON::ParserError => e
Rails.logger.warn("Assistant tool #{function_request.function_name} got invalid JSON arguments: #{e.class}: #{e.message}")
{
error: "Arguments were not valid JSON",
hint: "Re-send #{function_request.function_name} with valid JSON arguments."
}
rescue ActiveRecord::RecordNotFound => e
Rails.logger.warn("Assistant tool #{function_request.function_name} raised #{e.class}: #{e.message}")
# The raised message carries the scoped relation's full SQL, so returning
# it verbatim handed any caller the access-control schema: the tables, the
# owner/share join, the lot. MCP passes this straight through to an
# external client, which needs only a guessed UUID to read it. The message
# says nothing the caller can act on that the hint does not, and a
# not-found is deliberately indistinguishable from a forbidden id.
{
error: "No such record, or it is not one you have access to",
hint: "That record was not found. List valid options first (for example get_accounts or get_categories) and retry once with an exact match."
}
rescue Date::Error, ArgumentError, KeyError => e
Rails.logger.warn("Assistant tool #{function_request.function_name} raised #{e.class}: #{e.message}")
{
error: e.message,
hint: "Check argument formats (dates are YYYY-MM-DD) and retry once with corrected arguments."
}
rescue => e
Rails.logger.error("Assistant tool #{fn.name} failed: #{e.class}: #{e.message}")
{
error: "#{fn.name} failed unexpectedly",
hint: "Do not retry with the same arguments. Answer with the data you already have and note the gap."
}
end
def find_function(function_request)
functions.find { |f| f.name == function_request.function_name }
end
end