JJoeven

Curriculum/Python

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.

beginner21 min12 / 37

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.

A trace is boxes inside boxes
goalstepsone step

Open the outer dict, then the list, then one step. Walk one level at a time.

A trace is boxes inside boxes

A 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.

python
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 wantPath
The goaltrace["goal"]
How many stepslen(trace["steps"])
First steptrace["steps"][0]
The tool nametrace["steps"][1]["tool"]
The search querytrace["steps"][1]["args"]["q"]
Whether the tool workedtrace["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:

python
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.

FieldTypical homeMissing meansRead it with
goalouter tracethe run has no jobtrace["goal"] if you always set it
stepsouter traceempty run or a bad savetrace.get("steps", [])
argsassistant tool callthe model omitted inputsstep.get("args", {})
metaouter traceno extra debug bagtrace.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.

python
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.

python
trace[class="tok-s">"steps"][2][class="tok-s">"result"][class="tok-s">"ok"] = False

The 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 [].

python
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:

python
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.

ErrorTypical causeFix
KeyErrorsquare brackets on a missing key.get, or set the key when you build the row
IndexError[0] or [-1] on an empty listif steps: before indexing
TypeErroryou called .get on a string, list, or Nonecheck 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:

python
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"] when meta is optional.
  • Taking [0] without checking the list.
  • Assuming every step has the same keys.
  • Rebuilding the whole trace to flip one ok.
  • Using in on the outer dict to search for a nested value. in does not walk inside.
  • Calling .get on a value that is not a dict.
  • Mutating a compacted inner dict that is still shared with the saved log.
Live PythonOpen full playgroundpython
Output
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

How do you read a nested field without crashing if a key is missing?