GoogleCloudPlatform / GoogleCloudPlatform/BigQuery-Agent-Analytics-SDK

adk-2.0 design: workflow node-boundary event derivation (OTel vs event-observation)

Open
#207 2 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.