Build a Terse, Honest Tool-Result Envelope
Problem Statement
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).
Background
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".
Your Task
Implement:
def summarize_tool_result(result, char_budget):
- result: {"call_id": str, "tool": str, "rows": list[dict], "error": dict|None} - rows and error may be absent.
- char_budget: max length of content on the success path.
Input/Output Format
Returns the six-key dict described above. content for a fully-truncated result is the empty string.
Sample
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.
Example:
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.
Constraints:
char_budget >= 0; rows is at most 500 flat dicts of scalar values- Row keys are rendered in ascending sorted order, joined by
,ask=vwithstr(value) - Rows are joined by
"; ", and the separator counts against the budget - Greedy fill stops at the first row that does not fit - later, smaller rows are not backfilled
statusis"error", else"truncated"whenomitted > 0, else"ok"- Retryable error types:
timeout,rate_limit,server_error,overloaded - The envelope always carries all six keys, on both the success and error paths
1. Background Knowledge
In AI Agent architectures, the interaction between the agent and external tools (like databases, APIs, or calculators) is mediated by a structured communication protocol. The context window is a finite resource; every token consumed by tool outputs reduces the space available for reasoning, instructions, and memory. Therefore, tool responses must be optimized for information density and structural consistency. This problem simulates the creation of a "tool-result envelope," a standardized JSON-like structure that ensures the LLM can parse results reliably without hallucinating field names or formats.
The core concept here is deterministic serialization under budget constraints. Unlike standard JSON serialization, which aims for completeness, this task requires greedy truncation. The agent needs to know not just what data was returned, but how much was lost (omitted) and why (status). This meta-data allows the agent to decide whether to retry a request, ask for a subset of data, or proceed with partial information. The distinction between retryable errors (transient issues like timeouts) and non-retryable errors (logic issues like invalid arguments) is crucial for efficient agent loops, preventing wasted compute on futile retries.
Understanding string manipulation and length budgeting is essential. The "greedy" nature of the success path means you cannot simply slice the final string; you must evaluate the cost of each atomic unit (a rendered row) before committing it to the buffer. This mirrors real-world scenarios where data packets are dropped based on size limits, requiring the sender to report the count of dropped packets. The fixed schema (call_id, tool, status, etc.) acts as a contract, ensuring that the parsing logic on the LLM side remains static and robust across different tool types.
2. Algorithm Approach
The solution follows a conditional branching pattern based on the presence of an error in the input result.
- Error Handling Branch: Check if result contains a truthy error key. If so, classify the error type to determine retryable status. Construct the content string from the error type and message. Set status to "error" and omitted to 0.
- Success/Truncation Branch: If no error exists, process the rows.
- Pre-processing: Convert each row dictionary into a canonical string representation. This involves sorting keys alphabetically and formatting as k=v pairs joined by commas.
- Greedy Accumulation: Iterate through the pre-processed row strings. Maintain a running current_content string and a current_length counter.
- Budget Check: For each row, calculate the cost of adding it (row length + separator length if not the first row). If current_length + cost <= char_budget, append the row. Otherwise, stop immediately.
- Finalization: Count the number of rows skipped after the first one that didn't fit. Set status to "truncated" if any rows were omitted, otherwise "ok".
This approach separates data transformation (rendering rows) from resource management (budgeting), making the logic modular and easier to debug.
3. Step-by-Step Strategy
- Initialize Output Dict: Create a dictionary with keys call_id, tool, status, content, omitted, and retryable. Populate call_id and tool from the input result.
- Check for Errors:
- If result.get("error") is present and truthy:
- Extract type and message from the error dict.
- Set content to f"{type}: {message}".
- Define a set of retryable error types: {"timeout", "rate_limit", "server_error", "overloaded"}.
- Set retryable to True if the error type is in this set, else False.
- Set status to "error" and omitted to 0.
- Return the dict.
- Process Rows (Success Path):
- Initialize content_parts as an empty list and current_len as 0.
- Initialize omitted counter to 0.
- Iterate through each row in result.get("rows", []):
- Render Row: Sort the keys of the row dictionary. Join them as k=v with , separator. Let this string be row_str.
- Calculate Cost: The cost is len(row_str). If content_parts is not empty, add 2 for the "; " separator.
- Check Budget: If current_len + cost <= char_budget:
- Append row_str to content_parts.
- Update current_len += cost.
- Exceeds Budget:
- Increment omitted by 1.
- Break the loop immediately (do not check subsequent rows).
- Finalize Success Path:
- Join content_parts with "; " to form content.
- Set retryable to False (successes are not retries).
- Set status to "truncated" if omitted > 0, else "ok".
- Return: Return the completed dictionary.
4. Common Pitfalls
- Separator Overhead: Forgetting to account for the "; " separator in the length calculation is the most common bug. The first row has no preceding separator, but all subsequent rows do. Ensure the cost calculation adds 2 only when content_parts is not empty.
- Key Sorting: The problem specifies keys must be sorted ascending. Using dict.items() directly may preserve insertion order, which is incorrect. Always use sorted(row.keys()).
- Greedy vs. Optimal: The problem explicitly states to stop at the first row that does not fit. Do not attempt to skip a large row to fit smaller subsequent rows. This is a greedy algorithm, not a knapsack problem.
- Empty Rows: If rows is empty or missing, content should be an empty string, omitted should be 0, and status should be "ok". Ensure your loop handles empty lists gracefully.
- Error Type Matching: Ensure the comparison for retryable errors is case-sensitive and exact. A typo in the set of retryable types will lead to incorrect retryable flags.
- Missing Keys: Use .get() with defaults (e.g., result.get("rows", [])) to avoid KeyError if keys are absent, as the problem states rows and error may be absent.
5. Time & Space Complexity
- Time Complexity: O(N⋅KlogK), where N is the number of rows and K is the maximum number of keys in a single row. Sorting keys takes O(KlogK). Rendering each row takes O(K), and iterating through N rows takes O(N). The string concatenation and length checks are linear with respect to the total output size, which is bounded by char_budget.
- Space Complexity: O(B), where B is the char_budget. We store the content string which cannot exceed char_budget characters. The intermediate content_parts list also stores strings totaling at most B characters. The input rows are processed one by one, so we do not store all rendered rows in memory simultaneously if we optimize, but typically O(N⋅K) for storing the rendered rows before filtering is acceptable given the constraints.