ATIF converter emits dangling parent refs for subagents spawned without a tool call
bugc/tracestriagec/client
## Summary
The ATIF converter assumes every `subagent_trajectory_ref` hangs off a tool call. When a trajectory spawns sub-agents from a **system** step with no tool call — which real Harbor trajectories do routinely during context summarization — the converter points the child trajectories at a TOOL span that is never emitted. The children upload fine but land as orphans instead of nesting under the agent that spawned them.
## Reproduction
Two of the three real Harbor fixtures already in the repo hit this:
```bash
uv run python - <<'PY'
import json
from pathlib import Path
from phoenix.client.helpers.atif import upload_atif_trajectories_as_spans # or the internal converter
F = Path("packages/phoenix-client/tests/client/helpers/atif/fixtures")
names = ["harbor_terminus2_summarization.json", "harbor_terminus2_sub_questions.json",
"harbor_terminus2_sub_answers.json", "harbor_terminus2_sub_summary.json"]
# convert and check for parent_ids that reference no emitted span
PY
```
Result on `main`:
```
total spans: 37
dangling parents:
terminus-2-summarization-questions -> parent 6d7510869998eeec (not emitted)
terminus-2-summarization-answers -> parent 6d7510869998eeec (not emitted)
terminus-2-summarization-summary -> parent 6d7510869998eeec (not emitted)
```
Affected fixtures: `harbor_terminus2_summarization.json` and `harbor_terminus2_continuation.json`.
## Root cause
Step 5 of the summarization trajectory is a **system** step recording a runtime event, not a tool call:
```json
{
"step_id": 5,
"source": "system",
"message": "Performed context summarization and handoff to continue task.",
"observation": { "results": [ { "subagent_trajectory_ref": [ ...3 sub-agents... ] } ] }
}
```
There is no `tool_calls` array and the result carries no `source_call_id`.
`_build_subagent_ref_map` (`packages/phoenix-client/src/phoenix/client/helpers/atif/_convert.py`) registers a parent span ID for every ref by deriving a tool-span ID:
```python
parent_tool_span_id = _sha256_span_id(f"{span_seed}:step:{step_id}:tool:{tc_id}")
```
But TOOL spans are only emitted while iterating the step's `tool_calls` and keying on `tool_call_id`. With no tool call there is no TOOL span, so the derived ID refers to nothing. (System steps also never become spans at all, so today there is nothing on that step to attach to.)
Note the ATIF document is **not** malformed — it is correctly describing "the runtime spawned these sub-agents." The converter's assumption is what's too narrow. The v1.7 embedded-subagent fixture works precisely because its step *does* carry a matching `tool_calls` entry.
## Impact
- Wrong hierarchy, not data loss. Sub-agent spans upload and share the correct trace; they just appear as separate roots rather than nested.
- Hits `upload_atif_trajectories_as_spans`, which is public and documented ([importing ATIF trajectories](https://arize.com/docs/phoenix/tracing/how-to-tracing/importing-and-exporting-traces/importing-atif-trajectories)).
- Context summarization is routine in long Harbor runs, so this is common in real data rather than an edge case.
- The common-parent grouping path added in #15287 is unaffected: reparenting adopts spans whose parent is missing from the batch, so grouped trials stay connected. That is a backstop, not a fix for this.
## Options
1. **Fall back to the trajectory root.** When no tool span resolves, attach the sub-agents to the parent trajectory's root AGENT span. Truthful and small; loses the summarization message.
2. **Emit a span for the system step.** Nest the children under it. Most faithful and preserves the explanation, but invents a span with no LLM or tool behind it and breaks the rule that system steps aren't spans.
3. **Don't register unresolvable refs.** Children become parentless. Cleaner internally but produces the same multi-root trace on the plain upload path.
## Recommendation
Option 1, keeping the reparent-time adoption from #15287 as defense in depth. It fixes the shipped path in a few lines and cannot regress anything — the alternative today is a pointer to a span that does not exist. Option 2 is more faithful but raises a modeling question ("should runtime events become spans?") that deserves its own discussion rather than riding along with a bug fix.
Whichever option is chosen, the fix should assert the invariant directly: no emitted span may reference a `parent_id` that is absent from the batch.
## Context
Found while verifying #15287 against a live Phoenix with real Harbor trajectories.
0 条评论