GoogleCloudPlatform / GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK
adk-2.0 design: workflow node-boundary event derivation (OTel vs event-observation)
- Dominant language
- Python
- Stars
- 47
- Forks
- 21
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 33
Description
Parent tracker: #190 (v15 contract).
Wave: 3 — Design blocker.
Blocks: implementation of `WORKFLOW_NODE_STARTING` / `WORKFLOW_NODE_COMPLETED` events (no producer sub-issue filed yet — this design picks the path).
## Problem
ADK's `BasePlugin` exposes no node lifecycle callback. `WORKFLOW_NODE_STARTING` / `WORKFLOW_NODE_COMPLETED` events must be derived externally.
## Resolve with one of
(a) **Consume OTel `invoke_workflow` / `invoke_node` span start/end** (`src/google/adk/telemetry/`, semconv v1.36/v1.41). More accurate; adds an OTel dependency to the producer.
(b) **Deterministically derive from the first and last event observed per `attributes.adk.node.path` within an invocation.** Simpler; boundary detection is fuzzy for nodes that emit no events (no start/end can be observed).
## Acceptance
- [ ] Strategy chosen with rationale.
- [ ] If (a): document OTel exporter or in-process span consumption mechanism.
- [ ] If (b): document the heuristic for nodes with no events and document the time-window assumption.
- [ ] Follow-up producer sub-issue filed to implement the chosen strategy.
## References
- #190 (v15 producer §5).
- ADK telemetry: `src/google/adk/telemetry/`.
---
## Selected approach (v4)
This section captures the decision reached in the #297 v4 review thread.
### Strategy
**Deterministic event-observation derivation.** Boundary rows are derived from observed Events at each node path. No OTel-span dependency in the producer.
### Boundary scope: invocation-segment scoped
- **Key:** `(attributes.adk.app_name, user_id, session_id, invocation_id, node.path, node.run_id)`.
- **Rationale:** matches the plugin's current callback context; avoids fabricating cross-invocation lifecycle state from session-server reads.
- **Documented trade-off in `SDK.md`:** a single `node.run_id` can span multiple invocation segments. Resume-after-pause emits its own `WORKFLOW_NODE_STARTING` (same `node.path` and `node.run_id`, new `invocation_id`) and then `WORKFLOW_NODE_COMPLETED` if terminal. Consumers stitching cross-invocation lifecycle group on `(app_name, user_id, session_id, node.path, node.run_id)` and ignore `invocation_id`.
### Emit rules
- **`WORKFLOW_NODE_STARTING`** — emit on the first observed Event per the key above.
- **`WORKFLOW_NODE_COMPLETED`** — emit in the `after_run_callback` drain pass, for each tracked node `N` in this invocation:
1. Let `last_event_N` = the last Event observed at `N`.
2. If `last_event_N.long_running_tool_ids` is non-empty → **skip; leave `N` open.**
3. **Ancestor propagation:** if any descendant of `N` has been skipped under rule 2 in this drain pass → **skip `N`**. Implementation: walk descendants once at drain time and propagate `open` upward.
4. Otherwise → emit `WORKFLOW_NODE_COMPLETED` with `timestamp = last_event_N.timestamp`.
### No `WORKFLOW_NODE_PAUSED` event type
Open nodes are inferable from the absence of `COMPLETED` plus the latest `STARTING` **within the query window / retained data** (so dashboards using short windows should document the retention assumption). Introducing a third boundary type complicates consumer SQL without unblocking anything #211 needs.
### Silent-node gap
The event-observation strategy cannot emit boundary rows for nodes that emit zero Events. This is documented in `SDK.md` as a known limitation. If a customer later proves they need silent-node fidelity, add an opt-in `BQAA_WORKFLOW_BOUNDARY_FROM_OTEL=true` flag in a follow-up; keep event-observation as the default.
### Acceptance fixtures
- **Fresh-invocation-id resume** (default programmatic path): pause in invocation N, drain → no `COMPLETED`. Resume call with no `invocation_id` arg → new invocation N+1 (runner generates fresh ID per `runners.py:2046`) → new `STARTING` with same `node.path` and `node.run_id`, new `invocation_id` → terminal → `COMPLETED`.
- **Same-invocation-id resume** (CLI/API path): caller passes `interrupt_event.invocation_id` to `runner.run_async(invocation_id=...)` (per `cli/cli.py:733`). Pause segment leaves boundary open; resume segment under the same `invocation_id` emits `COMPLETED`. Verifies the contract still works when invocation_id happens to be reused.
- **Ancestor propagation**: `SequentialAgent` parent with child node. Child emits `long_running_tool_ids` → invocation drains. Assert: **neither parent nor child** gets `COMPLETED`. Source: `sequential_agent.py:99`, `parallel_agent.py:212` early returns.
- **Sibling no-bleed**: `SequentialAgent` with two siblings A, B. A pauses; B was never started. Drain runs. Assert: B has neither `STARTING` nor `COMPLETED` (B truly hasn't run); A has `STARTING` only (open).
### References
- #297 v4 — https://github.com/GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK/issues/297#issuecomment-4647000786
- `workflow/_node_runner.py:293-294` — workflow context tracks interrupt ids (`ctx._interrupt_ids.update(event.long_running_tool_ids)` at line 294).
- _Source anchors re-verified against `google/adk-python` `origin/main` on 2026-06-15._
Contributor guide
Assessment
This issue has not been assessed yet.