Nested Data (Traces)
Walk traces as dicts of lists of dicts. Change one field. Chain .get so missing keys do not crash an agent loop.
Nested means data inside other data. A trace (also called a transcript) is a log of what the agent did. In code it is often a dict that holds a list of step dicts. Tool results look the same: a dict, with lists, with dicts inside.
You walk one level at a time. You name each level: trace, steps, step, result, hits. Nested data looks scary until those names exist. Then it is just “open the form, open the row, read the field.” A missing branch is normal. A wrong type at a branch is also normal. Never assume every step has the same keys.
Open the outer dict, then the list, then one step. Walk one level at a time.
A trace is boxes inside boxesA fake agent trace
Read this like a form. The outer dict has a goal and a list of steps. Each step is a dict with a role (who spoke) and more fields.
trace = {
class="tok-s">"goal": class="tok-s">"book a flight",
class="tok-s">"steps": [
{class="tok-s">"role": class="tok-s">"user", class="tok-s">"content": class="tok-s">"book a flight to nyc"},
{class="tok-s">"role": class="tok-s">"assistant", class="tok-s">"tool": class="tok-s">"search", class="tok-s">"args": {class="tok-s">"q": class="tok-s">"flights nyc"}},
{class="tok-s">"role": class="tok-s">"tool", class="tok-s">"name": class="tok-s">"search", class="tok-s">"result": {class="tok-s">"ok": True, class="tok-s">"hits": 2}},
],
}
print(trace[class="tok-s">"goal"])
print(trace[class="tok-s">"steps"][0][class="tok-s">"role"])
print(trace[class="tok-s">"steps"][1][class="tok-s">"args"][class="tok-s">"q"])| You want | Path |
|---|---|
| The goal | trace["goal"] |
| How many steps | len(trace["steps"]) |
| First step | trace["steps"][0] |
| The tool name | trace["steps"][1]["tool"] |
| The search query | trace["steps"][1]["args"]["q"] |
| Whether the tool worked | trace["steps"][2]["result"]["ok"] |
Square brackets crash if a key or index is missing. That is useful when the field must exist. It is painful when a field is optional. Real traces are messy: some steps have tool, some have content, some have error. Optional fields need .get.
Print a middle level when you get lost:
print(trace[class="tok-s">"steps"][1])
print(trace[class="tok-s">"steps"][1][class="tok-s">"args"])Do not try to see the whole tree in your head. Print the node you are standing on.
Required vs optional fields
Not every key is the same kind of promise. Treat required keys as crashes you want. Treat optional keys as .get.
| Field | Typical home | Missing means | Read it with |
|---|---|---|---|
goal | outer trace | the run has no job | trace["goal"] if you always set it |
steps | outer trace | empty run or a bad save | trace.get("steps", []) |
args | assistant tool call | the model omitted inputs | step.get("args", {}) |
meta | outer trace | no extra debug bag | trace.get("meta", {}) |
Square brackets on missing meta kill the loop for a debug field. If your code always built steps, square brackets on steps are fine: a crash means you forgot the list.
Walkthrough: a tool result with hits
Tool results nest again. result is a dict. Inside it, hits is often a list. Each hit may be a dict with a title, or it may be a plain string. You do not know until you print a hit.
result = {class="tok-s">"ok": True, class="tok-s">"hits": [{class="tok-s">"title": class="tok-s">"Flight A"}, {class="tok-s">"title": class="tok-s">"Flight B"}]}
hits = result.get(class="tok-s">"hits", [])
print(class="tok-s">"how many hits:", len(hits))
if hits:
first = hits[0]
if isinstance(first, dict):
print(class="tok-s">"first title:", first.get(class="tok-s">"title", class="tok-s">""))
else:
print(class="tok-s">"first hit text:", first)Walk it in four names: result, hits, first, title. If you write result["hits"][0]["title"] in one shot, three failures look the same: missing hits, empty list, or a string hit. Named steps make the error obvious. Some tools return {"ok": True, "text": "..."} with no hits. Use .get("hits", []) and treat a missing list as “no rows.”
Change one field
You do not rebuild the whole log. You change one value. Other steps stay the same.
trace[class="tok-s">"steps"][2][class="tok-s">"result"][class="tok-s">"ok"] = FalseThe list still has three steps. The user message did not change. Only ok flipped. That is how you mark a tool error in a trace. You can also append a new step: trace["steps"].append({"role": "tool", "name": "search", "result": {"ok": False}}). Append is the usual way to grow a log. Editing an old ok is for corrections and tests.
If two names point at the same inner dict, editing through one name shows up through the other. That is the shared-object rule. Copy when you must isolate. For a single trace you own, in-place edits are normal.
When you compact a row for the next prompt, build a small new dict. Do not delete keys from the only copy you saved. Huge HTML in result blows the token budget; the full row is what you debug.
A safe .get chain
A safe .get chain means call .get at each level. Give a safe default when the next level might be missing. For a missing dict, the default is {}. For missing text, the default is "". For a missing list, the default is [].
source = trace.get(class="tok-s">"meta", {}).get(class="tok-s">"source", class="tok-s">"missing")
steps = trace.get(class="tok-s">"steps", [])
first = {}
if steps:
first = steps[0]
content = first.get(class="tok-s">"content", class="tok-s">"")
print(source)
print(content)If meta is missing, you get "missing" — not a crash. If steps is missing, you get []. Then you check before you take [0]. Never write trace.get("steps", [])[0] unless you already know the list is not empty. An empty list plus [0] is IndexError.
A long chain of ["a"]["b"]["c"]["d"] is brittle. Three .gets plus a named middle variable is readable. If you need four levels, you probably want a small helper function later.
Walk the list of steps with a loop when you need every row, not one path:
for i, step in enumerate(trace.get(class="tok-s">"steps", []), start=1):
role = step.get(class="tok-s">"role", class="tok-s">"?")
print(i, role, step.get(class="tok-s">"tool") or step.get(class="tok-s">"name") or class="tok-s">"")That print is a table of the trace. When a nested lookup fails, this loop still shows how far you got. If role is missing, you see ? instead of a crash. Helpers should take a step dict, not the whole trace, when they only need one row.
What goes wrong
Three errors show up constantly on nested traces. Learn the name, then you can fix the path.
| Error | Typical cause | Fix |
|---|---|---|
KeyError | square brackets on a missing key | .get, or set the key when you build the row |
IndexError | [0] or [-1] on an empty list | if steps: before indexing |
TypeError | you called .get on a string, list, or None | check isinstance(node, dict) before .get |
The TypeError is the sneaky one. A tool that failed may store result as the string "timeout" instead of {"ok": False}. Then result.get("ok") dies because strings have no .get. Guard:
result = step.get(class="tok-s">"result")
ok = False
if isinstance(result, dict):
ok = bool(result.get(class="tok-s">"ok"))in on the outer dict does not search nested values. "flights nyc" in trace is False. If args is a list, args.get("q") is TypeError. At the tool edge, require a dict. In a viewer, skip the wrong type and keep printing.
Common mistakes
trace["meta"]["source"]whenmetais optional.- Taking
[0]without checking the list. - Assuming every step has the same keys.
- Rebuilding the whole trace to flip one
ok. - Using
inon the outer dict to search for a nested value.indoes not walk inside. - Calling
.geton a value that is not a dict. - Mutating a compacted inner dict that is still shared with the saved log.
Run to execute this in your browser. Nothing is sent to a server.
Flip ok back to True in the editor and print hits again. One field changed. The list of hits did not need to be rebuilt. The loop at the end is the debug table you will copy into real traces.
How agents use this
A real trace is a dict of lists of dicts. Tool results nest again: result, then hits, then one hit. You walk one level at a time. You change one field, like ok, without rebuilding the log. Safe .get chains keep a missing key from stopping the agent. This is how traces and tool results look.
When you debug, print len(trace["steps"]) and the last step: trace["steps"][-1]. The last observation is usually the reason the next thought is wrong. You do not need a fancy viewer. You need a path and a print.
Parsers should produce this shape on purpose. If one tool returns a string and another returns a nested dict, the loop cannot treat observations the same. Normalize at the tool edge: always a dict with ok, then either result or error.
Prompt builders walk the same tree: take steps[-8:], drop huge result bodies, keep role and a short content or error. Build a new list of small dicts. Do not delete keys from the only copy on disk.
When a nested lookup fails, log the path you tried and type(result).__name__. “Expected dict, got str” is a one-line diagnosis. Rebuilding the agent will not fix a tool that returned a string.
Check your understanding