Whatever a tool returns goes straight back into the context window, where it competes with everything else the agent needs to remember. Three rules govern the hand-back: be terse (fit a budget), be honest about errors (say what failed and whether retrying could help), and be structured (a fixed envelope shape the model can learn once).
The envelope always has the same six keys, so the model never has to guess which fields exist:
| Key | Meaning | |---|---| | call_id | echoed from the request | | tool | echoed from the request | | status | "ok", "truncated" or "error" | | content | the rendered result, or the error line | | omitted | how many rows were dropped for budget | | retryable | whether a retry could plausibly succeed |
Error path. If result["error"] is present and truthy, the envelope is status="error", content = f"{type}: {message}", omitted=0, and retryable=True only when the error type is one of timeout, rate_limit, server_error, overloaded. A not_found or invalid_argument is the agent's fault - retrying it verbatim just burns a turn.
Success path. result["rows"] is a list of flat dicts. Render each row as its keys sorted ascending, joined as k=v with ,, and join rows with "; ". Then fill greedily: add whole rows in order while the running length (separators included) stays within char_budget, and stop at the first row that does not fit - do not skip it and try a smaller later row. Anything left over is counted in omitted; if omitted > 0 the status is "truncated" rather than "ok".
Implement:
def summarize_tool_result(result, char_budget):
Returns the six-key dict described above. content for a fully-truncated result is the empty string.
res = {"call_id": "t1", "tool": "sql", "rows": [{"b": 2, "a": 1}, {"a": 9, "b": 8}]}
print(summarize_tool_result(res, 8)["content"]) # a=1,b=2
print(summarize_tool_result(res, 8)["omitted"]) # 1
print(summarize_tool_result(res, 8)["status"]) # truncated
Row one renders as a=1,b=2 (7 chars, keys sorted). Adding row two would cost 2 more for "; " plus 7 more chars = 16 > 8, so it is dropped.
res = {'call_id': 't1', 'tool': 'sql', 'rows': [{'b': 2, 'a': 1}, {'a': 9, 'b': 8}]}
out = summarize_tool_result(res, 8)
print(out['content'], out['omitted'], out['status'])a=1,b=2 1 truncated
Keys are sorted, so the first row renders as 'a=1,b=2' - 7 characters, within the budget of 8. The second row would add '; ' (2) plus 'a=9,b=8' (7) for a total of 16, over budget, so it is omitted and the status downgrades to truncated.
char_budget >= 0; rows is at most 500 flat dicts of scalar values, as k=v with str(value)"; ", and the separator counts against the budgetstatus is "error", else "truncated" when omitted > 0, else "ok"timeout, rate_limit, server_error, overloaded