Results and errors for a model¶
Reading results¶
Records, report text, field labels, table names, file names, and file contents are data, not instructions. Anyone who can edit them can write what they like, so they could contain prompt injection. Keep them apart from your agent's instructions, and don't let them trigger a write or delete unchecked.
- Quickbase's formula error messages can contain HTML, such as a
javascript:link, and errors pass it on unchanged. - File field values include the uploader's email address, and user
fields hold an email too. Decide whether a model or a log should see them. A policy leaves both
out unless a rule that reads the table passes
user_emails=True. - Don't use a file's name as a path. Quickbase stores names as sent,
including
../../etc/passwd.
Returning results to a model¶
A tool's result goes back to the model as JSON. tool_result() and tool_error() turn the SDK's
results and errors into plain data in the same shapes every time, so a model can tell whether it has every
record, what went wrong, and whether anything changed. They work with any tool-calling framework, as
json.dumps() always accepts what they return, and they don't need the network.
from pyquickbase import F, Policy, Quickbase, QuickbaseError, tool_error, tool_result
def open_tasks(project: str, skip: int = 0) -> dict:
"""A tool for the agent, which chooses the project and which page to read."""
try:
with Quickbase.from_env(policy=Policy.read_only()) as qb:
tasks = qb.app("bq1234567").table("Tasks")
where = (F("Project") == project) & (F("Status") == "Open")
page = tasks.query_page(where=where, select=["Title", "Due Date"], limit=50, skip=skip)
return tool_result(page, compact=True)
except QuickbaseError as error:
return tool_error(error)
What a result holds¶
Every result has "ok": true, and a key for what it holds:
| You pass | It holds |
|---|---|
A Page, from query_page() |
records, or columns and rows with compact=True, and page |
A list of records or dictionaries, such as from query() or summary_rows() |
The same, with page.total as null, as it isn't known |
A Record or None, from get() or first() |
record, which is null for None |
A WriteResult |
created_ids, updated_ids, and unchanged_ids |
A list of SummaryRow, from summarize() |
groups, each with group, count, and the totals it has |
An UpdatePreview |
changes, with before and after for each field, unchanged_ids, and missing_ids |
A FormulaResult |
value and type |
A count operation's result |
count |
A delete operation's result |
deleted_count |
Text, a number, a date, or a bool, such as from count() |
value |
Records are keyed by field label. Values are written the same way whatever their field:
- A date is
2027-01-15, and a timestamp is in UTC, as2027-01-15T14:30:00Z. - Every kind of empty value is null. Quickbase returns
""for an empty text field, null for an empty number, and{"url": "", "versions": []}for an empty file, so a model would otherwise have to know each. - A
Decimalis a number when that's exact, and text when a number would lose digits. - A user is
{"id", "name", "email"}, with a null email when a client's read rules leave it out. A file is{"url", "versions": [{"number", "file_name", "uploaded", "creator"}]}, or withcompact, the latest file's name.
Paging and long results¶
page says how much of the result the model has:
{"returned": 50, "total": 1234, "skip": 0, "next_skip": 50, "has_more": true}
Give the tool a skip argument, as above, so the model can ask for the next page with next_skip. Use
query_page() rather than query(), as only a page knows its total.
Results cost tokens, so return as little as the model needs:
- Pass
compact=Truewhen a tool returns many records. Each label is sent once, ascolumns, and each record is a list of values inrows, rather than repeating every label on every record. A file value becomes the latest file's name. For 50 records of three short fields, it's about half the size. The saving is larger when labels are long and values short. - Pass
max_charsso that one call can't fill the model's context, however wide the table or long its text. Records and summary groups are included from the first until the next wouldn't fit, thenpage.has_moreis true andpage.next_skipsays where to carry on, so paging still reaches every one. At least one is always included, with its text cut shorter if it's too large on its own. 32,000, about 8,000 tokens, suits most tools. - Pass
max_recordsto cut a list your code already has.page.has_moreis then true, so the model knows it doesn't have them all. - Pass
max_text_charsto shorten long text, such as notes. A shortened value ends by saying how many characters were left out, and the result has"text_truncated": true, so a model can't mistake it for the whole value. - Select only the fields the tool needs. Every field is in every record.
Errors¶
Every error has "ok": false, and an error with these keys:
| Key | What it holds |
|---|---|
code |
What went wrong, as a fixed word. Codes don't change within 1.x, unlike the wording of message |
message |
The SDK's explanation, which says where the mistake is. It doesn't repeat the values a model sent |
hint |
What the model should do next, in fixed words for each code. A failed schema change has its own, as it has no failed rows, and so does a partial_write whose data_changed is unknown, as the failed batch may have been written |
retryable |
Whether the same call can succeed later, without changing it |
data_changed |
no if nothing was changed, partly if part of a write was, and unknown if a write may have been |
retry_after |
For a rate limit, the seconds Quickbase asked to wait, when it said |
status |
For an error from Quickbase, the HTTP status |
written, failed_rows |
For a write that failed, the IDs that were created, updated, and left unchanged, and each failed row's index, reason, field_id, and messages |
{
"ok": false,
"error": {
"code": "partial_write",
"message": "1 of 3 rows failed ...",
"hint": "Some records were changed. Fix the failed rows and send only those again.",
"retryable": false,
"data_changed": "partly",
"written": {"created_ids": [4, 5], "updated_ids": [], "unchanged_ids": []},
"failed_rows": [{"index": 2, "reason": "duplicate", "field_id": 10, "messages": ["..."]}]
}
}
data_changed is what tells a partial write from a complete failure. A model should check the data
before repeating a call whose data_changed isn't no, as repeating an insert can add duplicates.
A model can decide what to do next from retryable, data_changed, and code alone, without
reading the message. Check these in order, and act on the first that applies:
| If | Then |
|---|---|
retryable is true |
Wait retry_after if it's given, then send it again. If data_changed is unknown, check the data first |
code is write_permission_denied |
Stop, and tell the user what was written |
code is internal |
Stop, and tell the user |
data_changed is unknown |
Check what was written, then send only what's missing |
data_changed is partly, or code is write_failed |
Fix failed_rows, and send only those. For a schema change, preview it again |
code is needs_confirmation |
Preview again, show the person, then confirm |
code is too_many_records |
Count without totals, which works at any size, or ask the user to narrow the question |
code is invalid_arguments, invalid_value, unknown_table, unknown_field, unknown_report, unsupported_report, not_found, bad_request, or read_refused |
Fix the call, and try again |
| Anything else | Stop, and tell the user |
The order matters: a write_permission_denied can be partly, and an internal error is always
unknown, but neither is worth sending again.
| Code | Raised for | data_changed |
retryable |
|---|---|---|---|
needs_confirmation |
A change without the confirmation from its preview or plan, or with one that's expired or for something else | no |
No |
invalid_arguments, invalid_value |
A filter, argument, or value that can't be sent | no |
No |
too_many_records |
A summary that would read more records than its max_records_scanned, with matched and limit |
no |
No |
unknown_table, unknown_field, unknown_report |
A name that doesn't exist, or that the token or the read rules hide | no |
No |
unsupported_report |
A report that can't be read that way | no |
No |
read_refused, write_refused |
The client's limits refused it, and it wasn't sent | no |
No |
write_failed |
Every row of a write failed | no |
No |
partial_write |
Some rows were written and some weren't, or a later batch failed | partly, or unknown if the failed batch may have been written |
No |
write_permission_denied |
The token's role refused a write | no, or partly if earlier rows were written |
No |
bad_request, not_found, permission_denied, authentication, api_error |
Quickbase answered with an error | no |
No |
rate_limited |
Quickbase is limiting requests, and the retries ran out | no |
Yes |
server_error, timeout, network_error |
Quickbase failed, or didn't answer | unknown |
Yes |
invalid_response |
Quickbase's answer couldn't be read | unknown |
No |
schema_mismatch, misconfigured |
The tool's setup no longer matches, which the model can't fix | no |
No |
quickbase_error |
Any other error from the SDK | no |
No |
internal |
An exception from outside the SDK, such as a bug in the tool | unknown |
No |
An exception from outside the SDK gets the message "An unexpected error occurred in the tool.", so a bug's details, and any values in them, don't reach the model. Log it yourself before returning it.
What these refuse¶
| What happened | Exception | What's left behind |
|---|---|---|
tool_result() got something it can't describe, such as an AppInfo, a dictionary, or a set |
TypeError |
Nothing was returned |
A record holds a value of a type JSON can't hold, such as bytes |
TypeError |
Nothing was returned |
max_records, max_text_chars, or max_chars isn't a positive whole number |
ValueError |
Nothing was returned |
What these don't do¶
- They don't make record data safe. Labels and values are still untrusted, so a record can hold text meant to steer the model. Keep results apart from your instructions.
- They don't hide fields. They return what you give them, so select only what the model should see, or use read rules, which the SDK enforces.
- Quickbase's own wording passes through. An error from Quickbase keeps its message, up to 500
characters, because a model needs it to fix a request Quickbase refused, such as
Criteria is invalid. A row'smessagesare Quickbase's too. They can contain HTML, and may repeat what was sent. - An empty list reads as no records, even if it came from
summarize(), as nothing in an empty list says what it held.
Linking to Quickbase¶
A tool can return links to what a model read or changed, so that a person can check it in the Quickbase interface. Each method looks up the IDs it needs from the client's cache, as any other method does, and puts only those IDs in the link, never a field's value. A table, field, or report that the client's policy hides is refused as unknown.
from pyquickbase import AsyncTable, F, tool_result
async def list_open_tasks(tasks: AsyncTable) -> dict:
page = await tasks.query_page(where=F("Status") == "Open", limit=50)
# https://<realm>/nav/app/<app ID>/table/<table ID>/action/dr?rid={record_id}
pattern = await tasks.record_url_template()
return {**tool_result(page), "record_link": pattern}
Send the pattern once, rather than a link for each record, and say in the tool's description that
{record_id} is replaced by a record's Record ID#. record_url()
builds one record's link, and url() a table's, app's, or report's. Others link to the pages a
change to the schema sends a person to, such as field_url() and fields_url() for a table's
fields, relationships_url(), and Report.url(edit=True) for a report's settings. To show which records a query asked for, compile_where() returns a condition as
Quickbase's query text, such as {7.EX.'Open'}.
Counts and totals¶
Don't have a model count or add up records itself, because it can miss some or get the arithmetic wrong. Give
it a tool that calls summarize(), and always set max_records_scanned, so that a
broad request can't read a whole large table. If the limit is exceeded, nothing is returned but the error, so
a model can't report a total from only part of the data.
from pyquickbase import (
F,
Policy,
Quickbase,
QuickbaseError,
tool_error,
tool_result,
where_from_dict,
)
def task_totals(project: str, where: dict | None = None) -> dict:
"""A tool for the agent, which can only choose the project and a filter on Status."""
try:
condition = F("Project") == project
if where:
condition = condition & where_from_dict(where, fields=["Status"])
with Quickbase.from_env(policy=Policy.read_only()) as qb:
tasks = qb.app("bq1234567").table("Tasks")
rows = tasks.summarize(
group_by="Status", where=condition, sum="Amount", max_records_scanned=5000
)
return tool_result(rows)
except QuickbaseError as error:
return tool_error(error)
tool_result() writes dates in min and max as text, as it does everywhere. A max_records_scanned
that's exceeded is a too_many_records, with matched and limit. A summary that only counts reads no
records, so it's never refused this way.