rename trigger_call_id to parent_task_id

Pregel doesn't have a "call" concept — its unit of work is a task. The
namespace segment after `:` is documented in `_internal/_constants.py`
as the `task_id` ("for checkpoint_ns, for each level, separates the
namespace from the task_id"), and "parent task" already shows up as
existing pregel vocabulary (`_algo.py`, `_runner.py`).

`parent_task_id` matches that vocabulary: from the child subgraph's
perspective, the id we carry on every lifecycle event identifies the
task in the parent scope whose execution dispatched this subgraph.
Renames the wire field, the `SubgraphRunStream.parent_task_id`
attribute, all internal parameter / variable names, and the test
identifiers.
This commit is contained in:
Nick Hollon
2026-05-08 11:38:27 -04:00
parent 8c4bfa6d07
commit 58e0773ec6
4 changed files with 59 additions and 59 deletions
@@ -523,7 +523,7 @@ class _SubgraphRunStreamMixin:
path: tuple[str, ...]
graph_name: str | None
trigger_call_id: str | None
parent_task_id: str | None
status: LifecycleEvent
error: str | None
_seen_terminal: bool
@@ -538,7 +538,7 @@ class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
*,
path: tuple[str, ...],
graph_name: str | None = None,
trigger_call_id: str | None = None,
parent_task_id: str | None = None,
) -> None:
# Capture the parent-inherited pump before super().__init__
# touches anything; we delegate to it from `_pump_next`.
@@ -550,7 +550,7 @@ class SubgraphRunStream(GraphRunStream, _SubgraphRunStreamMixin):
)
self.path = path
self.graph_name = graph_name
self.trigger_call_id = trigger_call_id
self.parent_task_id = parent_task_id
self.status = "started"
self.error = None
self._seen_terminal = False
@@ -581,7 +581,7 @@ class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
*,
path: tuple[str, ...],
graph_name: str | None = None,
trigger_call_id: str | None = None,
parent_task_id: str | None = None,
) -> None:
self._parent_apump_fn: Callable[[], Awaitable[bool]] | None = mux._apump_fn
super().__init__(
@@ -591,7 +591,7 @@ class AsyncSubgraphRunStream(AsyncGraphRunStream, _SubgraphRunStreamMixin):
)
self.path = path
self.graph_name = graph_name
self.trigger_call_id = trigger_call_id
self.parent_task_id = parent_task_id
self.status = "started"
self.error = None
self._seen_terminal = False
+39 -39
View File
@@ -333,7 +333,7 @@ LifecycleEvent = Literal["started", "completed", "failed", "interrupted", "drain
Each value:
- `started` — first `tasks` event observed at the tracked namespace. Carries
`graph_name` / `trigger_call_id` / optional `cause` describing what spawned
`graph_name` / `parent_task_id` / optional `cause` describing what spawned
the subgraph.
- `completed` — the dispatching task's `TaskResultPayload` arrived with neither
error nor interrupts. Also emitted by `finalize` for any tracked namespace
@@ -351,7 +351,7 @@ Each value:
def _parse_ns_segment(segment: str) -> tuple[str, str | None]:
"""Split a namespace segment into `(graph_name, trigger_call_id)`.
"""Split a namespace segment into `(graph_name, parent_task_id)`.
Segments are formatted `node_name:task_id` by `prepare_next_tasks`.
Returns `(segment, None)` if no `:` is present.
@@ -416,7 +416,7 @@ class LifecyclePayload(TypedDict, total=False):
split (it does for normally-dispatched subgraphs). Absent if the segment
has no `:` separator.
"""
trigger_call_id: str
parent_task_id: str
"""Pregel task id of the dispatching task — the task whose execution
spawned this subgraph.
@@ -465,10 +465,10 @@ class _TasksLifecycleBase(StreamTransformer):
- `_should_track(ns)` — scope filter (e.g. multi-depth vs
direct-children-only).
- `_on_started(ns, graph_name, trigger_call_id, tool_call_id)` —
- `_on_started(ns, graph_name, parent_task_id, tool_call_id)` —
first sighting action (push payload / build handle / etc.).
Called once per discovered namespace.
- `_on_terminal(ns, status, error, trigger_call_id)` — terminal
- `_on_terminal(ns, status, error, parent_task_id)` — terminal
action (push terminal payload / mark handle status). Called
once per tracked namespace at result time, or via `finalize` /
`fail` sweeps if no parent result arrived.
@@ -504,7 +504,7 @@ class _TasksLifecycleBase(StreamTransformer):
self,
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
parent_task_id: str | None,
tool_call_id: str | None = None,
) -> None:
"""Fired once per discovered namespace (first observed task event).
@@ -512,7 +512,7 @@ class _TasksLifecycleBase(StreamTransformer):
`tool_call_id` is the model-side id of the originating tool call
(from the per-call dispatched task's `input`). `None` for
structurally-triggered subgraphs or per-call envelopes that omitted
an `id`. Consumers join on `trigger_call_id` (the pregel task id)
an `id`. Consumers join on `parent_task_id` (the pregel task id)
for identity; `tool_call_id` is purely an anchor back to the AI
message that dispatched the subgraph.
"""
@@ -523,12 +523,12 @@ class _TasksLifecycleBase(StreamTransformer):
ns: tuple[str, ...],
status: LifecycleEvent,
error: str | None,
trigger_call_id: str,
parent_task_id: str,
) -> None:
"""Fired once per tracked namespace when its dispatching task's
result arrives, or via finalize/fail safety-net sweeps.
`trigger_call_id` is the same id paired with the namespace at
`parent_task_id` is the same id paired with the namespace at
`_on_started` time, so subscribers can correlate the terminal
event back to its `started`.
"""
@@ -553,26 +553,26 @@ class _TasksLifecycleBase(StreamTransformer):
def _handle_task_start(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
# Mine input shape on every tasks event (not just tracked ones)
# so we capture dispatching tasks that themselves live outside the
# tracked region but whose `id` will appear as `trigger_call_id`
# tracked region but whose `id` will appear as `parent_task_id`
# for a child subgraph.
self._record_dispatching_tool_call_id(data)
if not self._should_track(ns) or ns in self._seen:
return
self._seen.add(ns)
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
graph_name, parent_task_id = _parse_ns_segment(ns[-1])
tool_call_id = (
self._dispatching_tool_call_id.pop(trigger_call_id, None)
if trigger_call_id is not None
self._dispatching_tool_call_id.pop(parent_task_id, None)
if parent_task_id is not None
else None
)
self._on_started(
ns,
graph_name or None,
trigger_call_id,
parent_task_id,
tool_call_id,
)
if trigger_call_id is not None:
self._open[ns] = trigger_call_id
if parent_task_id is not None:
self._open[ns] = parent_task_id
def _record_dispatching_tool_call_id(self, data: dict[str, Any]) -> None:
"""Remember `task_id -> tool_call_id` if the task input matches
@@ -597,8 +597,8 @@ class _TasksLifecycleBase(StreamTransformer):
) -> list[tuple[tuple[str, ...], LifecycleEvent, str | None, str]]:
"""Return and remove tracked children closed by this task result.
Each tuple is `(child_ns, status, error, trigger_call_id)`.
`trigger_call_id` is the dispatching task's id — the same id
Each tuple is `(child_ns, status, error, parent_task_id)`.
`parent_task_id` is the dispatching task's id — the same id
we'd already paired with the namespace at `_on_started`.
"""
result_id = data.get("id")
@@ -614,22 +614,22 @@ class _TasksLifecycleBase(StreamTransformer):
return transitions
def _handle_task_result(self, ns: tuple[str, ...], data: dict[str, Any]) -> None:
for child_ns, status, error, trigger_call_id in self._pop_terminal_transitions(
for child_ns, status, error, parent_task_id in self._pop_terminal_transitions(
ns, data
):
self._on_terminal(child_ns, status, error, trigger_call_id)
self._on_terminal(child_ns, status, error, parent_task_id)
def finalize(self) -> None:
"""Emit `completed` for any tracked namespace still open at run end."""
for ns, trigger_call_id in list(self._open.items()):
self._on_terminal(ns, "completed", None, trigger_call_id)
for ns, parent_task_id in list(self._open.items()):
self._on_terminal(ns, "completed", None, parent_task_id)
self._open.clear()
def fail(self, err: BaseException) -> None:
"""Emit terminal status for any tracked namespace still open."""
status, error_str = _status_from_exception(err)
for ns, trigger_call_id in list(self._open.items()):
self._on_terminal(ns, status, error_str, trigger_call_id)
for ns, parent_task_id in list(self._open.items()):
self._on_terminal(ns, status, error_str, parent_task_id)
self._open.clear()
@@ -693,10 +693,10 @@ class LifecycleTransformer(_TasksLifecycleBase):
self,
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
parent_task_id: str | None,
tool_call_id: str | None = None,
) -> None:
if trigger_call_id is None:
if parent_task_id is None:
# Without a task id we can't correlate a dispatching-task-result
# event back to this namespace — skip the started payload
# and rely on finalize/fail to close.
@@ -704,7 +704,7 @@ class LifecycleTransformer(_TasksLifecycleBase):
payload: LifecyclePayload = {
"event": "started",
"namespace": list(ns),
"trigger_call_id": trigger_call_id,
"parent_task_id": parent_task_id,
}
if graph_name:
payload["graph_name"] = graph_name
@@ -717,12 +717,12 @@ class LifecycleTransformer(_TasksLifecycleBase):
ns: tuple[str, ...],
status: LifecycleEvent,
error: str | None,
trigger_call_id: str,
parent_task_id: str,
) -> None:
payload: LifecyclePayload = {
"event": status,
"namespace": list(ns),
"trigger_call_id": trigger_call_id,
"parent_task_id": parent_task_id,
}
if error is not None:
payload["error"] = error
@@ -776,7 +776,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
self,
ns: tuple[str, ...],
graph_name: str | None,
trigger_call_id: str | None,
parent_task_id: str | None,
tool_call_id: str | None = None, # noqa: ARG002
) -> None:
if self._mux is None:
@@ -790,7 +790,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
mux=child_mux,
path=ns,
graph_name=graph_name,
trigger_call_id=trigger_call_id,
parent_task_id=parent_task_id,
)
self._handles[ns] = handle
self._log.push(handle)
@@ -800,7 +800,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
ns: tuple[str, ...],
status: LifecycleEvent,
error: str | None,
trigger_call_id: str, # noqa: ARG002
parent_task_id: str, # noqa: ARG002
) -> None:
handle = self._handles.get(ns)
if handle is None or not self._mark_terminal(handle, status, error):
@@ -812,7 +812,7 @@ class SubgraphTransformer(_TasksLifecycleBase):
ns: tuple[str, ...],
status: LifecycleEvent,
error: str | None,
trigger_call_id: str, # noqa: ARG002
parent_task_id: str, # noqa: ARG002
) -> None:
handle = self._handles.get(ns)
if handle is None or not self._mark_terminal(handle, status, error):
@@ -893,9 +893,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
child_ns,
status,
error,
trigger_call_id,
parent_task_id,
) in self._pop_terminal_transitions(ns, data):
await self._aon_terminal(child_ns, status, error, trigger_call_id)
await self._aon_terminal(child_ns, status, error, parent_task_id)
else:
self._handle_task_start(ns, data)
keep = False
@@ -909,9 +909,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
def _complete_open_handles(self) -> BaseException | None:
first_error: BaseException | None = None
for ns, trigger_call_id in list(self._open.items()):
for ns, parent_task_id in list(self._open.items()):
try:
self._on_terminal(ns, "completed", None, trigger_call_id)
self._on_terminal(ns, "completed", None, parent_task_id)
except BaseException as e:
if first_error is None:
first_error = e
@@ -927,9 +927,9 @@ class SubgraphTransformer(_TasksLifecycleBase):
async def _acomplete_open_handles(self) -> BaseException | None:
first_error: BaseException | None = None
for ns, trigger_call_id in list(self._open.items()):
for ns, parent_task_id in list(self._open.items()):
try:
await self._aon_terminal(ns, "completed", None, trigger_call_id)
await self._aon_terminal(ns, "completed", None, parent_task_id)
except BaseException as e:
if first_error is None:
first_error = e
@@ -131,7 +131,7 @@ def test_started_emitted_on_first_direct_child_task() -> None:
assert payload["event"] == "started"
assert payload["namespace"] == ["agent:abc123"]
assert payload["graph_name"] == "agent"
assert payload["trigger_call_id"] == "abc123"
assert payload["parent_task_id"] == "abc123"
def test_started_carries_cause_for_dict_envelope_input() -> None:
@@ -142,7 +142,7 @@ def test_started_carries_cause_for_dict_envelope_input() -> None:
When that task triggers a subgraph (the child's namespace ends in
`name:<dispatching_task_id>`), `lifecycle.started.cause` carries
`{"type": "tool_call", "tool_call_id": ...}`. Identity correlation
still uses `trigger_call_id`; `tool_call_id` is exposed so UI
still uses `parent_task_id`; `tool_call_id` is exposed so UI
consumers can anchor the lifecycle event back to the originating
AI message tool call. Args are deliberately NOT mined — they live
on the AIMessage and have a single source of truth there.
@@ -166,7 +166,7 @@ def test_started_carries_cause_for_dict_envelope_input() -> None:
[payload] = _drain_lifecycle(mux)
assert payload["event"] == "started"
assert payload["trigger_call_id"] == "abc123"
assert payload["parent_task_id"] == "abc123"
assert payload["cause"] == {
"type": "tool_call",
"tool_call_id": "call_xyz",
@@ -200,7 +200,7 @@ def test_started_carries_cause_for_list_shape_per_call_input() -> None:
[payload] = _drain_lifecycle(mux)
assert payload["event"] == "started"
assert payload["trigger_call_id"] == "abc123"
assert payload["parent_task_id"] == "abc123"
assert payload["cause"] == {"type": "tool_call", "tool_call_id": "tc-1"}
@@ -284,7 +284,7 @@ def test_parallel_dispatches_attributed_to_correct_parent() -> None:
out to their own child subgraph; each child's `cause.tool_call_id`
must reflect its own dispatching envelope, not the other.
Defends the `trigger_call_id` (pregel task id) join: that id is
Defends the `parent_task_id` (pregel task id) join: that id is
parsed from the child namespace segment and is unique per Send,
so it disambiguates parallel dispatches 1:1. Both children share
the same `subagent_type` (in args, not on cause) — only the
@@ -411,10 +411,10 @@ def test_completed_on_parent_task_result() -> None:
payloads = _drain_lifecycle(mux)
assert [p["event"] for p in payloads] == ["started", "completed"]
# `trigger_call_id` is required on every event for the same subgraph
# `parent_task_id` is required on every event for the same subgraph
# so consumers can correlate `started` ↔ terminal without joining
# via `namespace`.
assert all(p["trigger_call_id"] == "abc" for p in payloads)
assert all(p["parent_task_id"] == "abc" for p in payloads)
def test_failed_on_parent_task_result_with_error() -> None:
@@ -490,10 +490,10 @@ def test_fail_emits_failed_for_other_exceptions() -> None:
assert payloads[1]["error"] == "boom"
def test_trigger_call_id_present_on_every_terminal_path() -> None:
def test_parent_task_id_present_on_every_terminal_path() -> None:
"""Every exit path that emits a terminal event (parent-result with
error / interrupts, finalize sweep, fail sweep) must carry
`trigger_call_id` so consumers can correlate the terminal event
`parent_task_id` so consumers can correlate the terminal event
back to its `started` without falling back to namespace joins."""
# Path 1: parent-result with error.
mux = _build_lifecycle_mux()
@@ -501,7 +501,7 @@ def test_trigger_call_id_present_on_every_terminal_path() -> None:
mux.push(_tasks_result([], task_id="abc", name="agent", error="boom"))
[_, terminal] = _drain_lifecycle(mux)
assert terminal["event"] == "failed"
assert terminal["trigger_call_id"] == "abc"
assert terminal["parent_task_id"] == "abc"
# Path 2: parent-result with interrupts.
mux2 = _build_lifecycle_mux()
@@ -511,7 +511,7 @@ def test_trigger_call_id_present_on_every_terminal_path() -> None:
)
[_, terminal2] = _drain_lifecycle(mux2)
assert terminal2["event"] == "interrupted"
assert terminal2["trigger_call_id"] == "def"
assert terminal2["parent_task_id"] == "def"
# Path 3: finalize sweep (no parent result arrived).
mux3 = _build_lifecycle_mux()
@@ -519,7 +519,7 @@ def test_trigger_call_id_present_on_every_terminal_path() -> None:
mux3.close()
[_, terminal3] = _drain_lifecycle(mux3)
assert terminal3["event"] == "completed"
assert terminal3["trigger_call_id"] == "ghi"
assert terminal3["parent_task_id"] == "ghi"
# Path 4: fail sweep with GraphInterrupt.
mux4 = _build_lifecycle_mux()
@@ -527,7 +527,7 @@ def test_trigger_call_id_present_on_every_terminal_path() -> None:
mux4.fail(GraphInterrupt())
[_, terminal4] = _drain_lifecycle(mux4)
assert terminal4["event"] == "interrupted"
assert terminal4["trigger_call_id"] == "jkl"
assert terminal4["parent_task_id"] == "jkl"
# Path 5: fail sweep with generic exception.
mux5 = _build_lifecycle_mux()
@@ -535,7 +535,7 @@ def test_trigger_call_id_present_on_every_terminal_path() -> None:
mux5.fail(RuntimeError("kaboom"))
[_, terminal5] = _drain_lifecycle(mux5)
assert terminal5["event"] == "failed"
assert terminal5["trigger_call_id"] == "mno"
assert terminal5["parent_task_id"] == "mno"
def test_unrelated_methods_pass_through() -> None:
@@ -195,7 +195,7 @@ def test_handle_created_on_first_direct_child_task() -> None:
[handle] = _drain_subgraphs(mux)
assert handle.path == ("agent:abc",)
assert handle.graph_name == "agent"
assert handle.trigger_call_id == "abc"
assert handle.parent_task_id == "abc"
assert handle.status == "started"
_child_mux(handle) # mini-mux backed