-
Notifications
You must be signed in to change notification settings - Fork 9
feat(harness): public adk facade + docs for the unified harness surface (PR 9) #423
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+219
−0
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6db534f
feat(harness): public adk facade for the unified surface + docs (AGX1…
declan-scale 7655ad9
docs: PR 10 post-merge cleanup plan (remove deprecated tracing handle…
declan-scale a6f2d89
docs(harness): drop unused deprecated import from sync example [grept…
declan-scale 4797963
docs(harness-cleanup): add cross-PR duplication consolidation to PR 1…
declan-scale 289fd60
docs(harness-cleanup): plan retirement of duplicate pre-unified tutor…
declan-scale 8680163
docs(harness-cleanup): settle tutorial naming on numbered paradigm
declan-scale f58cfc9
docs(harness-cleanup): move PR 10 plan out of PR 9 into the pr10 branch
declan-scale File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| # Unified Harness Surface | ||
|
|
||
| The unified harness surface gives every agent harness (pydantic-ai, LangGraph, OpenAI Agents, and future parsers) a single, shared path to streaming, message persistence, and tracing. The Agentex `StreamTaskMessage*` event stream is the canonical wire format. A harness tap produces that stream once; the shared machinery delivers it and derives spans from it. | ||
|
|
||
| All public names are re-exported from `agentex.lib.adk`: | ||
|
|
||
| ```python | ||
| from agentex.lib.adk import ( | ||
| UnifiedEmitter, | ||
| SpanTracer, | ||
| TurnUsage, | ||
| TurnResult, | ||
| HarnessTurn, | ||
| StreamTaskMessage, | ||
| OpenSpan, | ||
| CloseSpan, | ||
| SpanSignal, | ||
| ) | ||
| ``` | ||
|
|
||
| The implementation lives at `src/agentex/lib/core/harness/`. | ||
|
|
||
| --- | ||
|
|
||
| ## The canonical stream: `StreamTaskMessage` | ||
|
|
||
| `StreamTaskMessage` is a union of the four wire-protocol update types: | ||
|
|
||
| ``` | ||
| StreamTaskMessageStart - opens a content slot (text, reasoning, tool request, ...) | ||
| StreamTaskMessageDelta - appends a token/fragment to an open slot | ||
| StreamTaskMessageFull - posts a complete message in one shot (tool response, ...) | ||
| StreamTaskMessageDone - closes an open slot | ||
| ``` | ||
|
|
||
| Every harness tap produces a sequence of these. Everything downstream (delivery, tracing) reads the same sequence. | ||
|
|
||
| --- | ||
|
|
||
| ## Per-harness taps: `convert_<harness>_to_agentex_events` | ||
|
|
||
| A tap is an async generator that translates the harness's native event stream into `StreamTaskMessage*` events. The currently shipped taps are: | ||
|
|
||
| | Harness | Tap function | Exported from | | ||
| |---|---|---| | ||
| | pydantic-ai | `convert_pydantic_ai_to_agentex_events` | `agentex.lib.adk` | | ||
| | LangGraph | `convert_langgraph_to_agentex_events` | `agentex.lib.adk` | | ||
|
|
||
| Taps for claude-code and codex will be added in subsequent PRs (AGX1-420, AGX1-421) and exported from `agentex.lib.adk` in the same way. | ||
|
|
||
| --- | ||
|
|
||
| ## `HarnessTurn` protocol | ||
|
|
||
| `HarnessTurn` is the interface a harness turn object must satisfy to plug into `UnifiedEmitter`: | ||
|
|
||
| ```python | ||
| @runtime_checkable | ||
| class HarnessTurn(Protocol): | ||
| @property | ||
| def events(self) -> AsyncIterator[StreamTaskMessage]: ... | ||
|
|
||
| def usage(self) -> TurnUsage: ... | ||
| ``` | ||
|
|
||
| `events` is the canonical stream for this turn. `usage()` is valid only after `events` is exhausted (async generators cannot cleanly return a value to the consumer, so usage travels out-of-band). | ||
|
|
||
| --- | ||
|
|
||
| ## `TurnUsage` | ||
|
|
||
| Token counts and cost for one turn, harness-independent: | ||
|
|
||
| ```python | ||
| class TurnUsage(BaseModel): | ||
| model: str | None = None | ||
| input_tokens: int | None = None | ||
| output_tokens: int | None = None | ||
| cached_input_tokens: int | None = None | ||
| reasoning_tokens: int | None = None | ||
| total_tokens: int | None = None | ||
| cost_usd: float | None = None | ||
| duration_ms: int | None = None | ||
| num_llm_calls: int = 0 | ||
| num_tool_calls: int = 0 | ||
| num_reasoning_blocks: int = 0 | ||
| ``` | ||
|
|
||
| Field names align with `agentex.lib.core.observability.llm_metrics` for easy conversion. | ||
|
|
||
| --- | ||
|
|
||
| ## `UnifiedEmitter` | ||
|
|
||
| `UnifiedEmitter` ties a turn's canonical stream, tracing context, and delivery mode together. Construct one per turn with the task/trace context from the request: | ||
|
|
||
| ```python | ||
| emitter = UnifiedEmitter( | ||
| task_id=params.task.id, | ||
| trace_id=params.task.id, # or None to disable tracing | ||
| parent_span_id=turn_span.id if turn_span else None, | ||
| ) | ||
| ``` | ||
|
|
||
| **Tracing is on by default** when `trace_id` is provided. To disable it explicitly, pass `tracer=False`. To inject a custom `SpanTracer` (e.g. in tests), pass it as `tracer=<instance>`. | ||
|
|
||
| ### Delivery mode 1: `yield_turn` (sync HTTP ACP) | ||
|
|
||
| For sync ACP agents that return events directly over the HTTP response: | ||
|
|
||
| ```python | ||
| @acp.on_message_send | ||
| async def handle(params): | ||
| turn = MyHarnessTurn(params) # implements HarnessTurn | ||
| async for event in emitter.yield_turn(turn): | ||
| yield event | ||
| ``` | ||
|
|
||
| `yield_turn` forwards each event to the caller and traces spans as a side effect. It is a passthrough when `tracer` is `None`. | ||
|
|
||
| ### Delivery mode 2: `auto_send_turn` (async/Temporal) | ||
|
|
||
| For async or Temporal agents that push to the task stream via Redis: | ||
|
|
||
| ```python | ||
| result: TurnResult = await emitter.auto_send_turn(turn, created_at=workflow.now()) | ||
| ``` | ||
|
|
||
| `auto_send_turn` drives `adk.streaming` contexts for every message in the stream, derives and records spans, and returns a `TurnResult` with the final text and usage. Pass `created_at` under Temporal to back-date message timestamps deterministically. | ||
|
|
||
| --- | ||
|
|
||
| ## `TurnResult` | ||
|
|
||
| ```python | ||
| class TurnResult(BaseModel): | ||
| final_text: str = "" | ||
| usage: TurnUsage = TurnUsage() | ||
| ``` | ||
|
|
||
| Returned by `auto_send_turn`. `final_text` is the last text segment of the turn (multi-step runs return only the final segment, matching `stream_langgraph_events` / `stream_pydantic_ai_events` semantics). | ||
|
|
||
| --- | ||
|
|
||
| ## Tracing: span derivation | ||
|
|
||
| Spans are derived from the canonical stream by `SpanDeriver` (pure, no `adk` dependency) and dispatched to `adk.tracing` by `SpanTracer`. The mapping: | ||
|
|
||
| - `StreamTaskMessageStart(ToolRequestContent)` + `StreamTaskMessageDone` on that index -> tool span open (keyed by `tool_call_id`) | ||
| - `StreamTaskMessageFull(ToolResponseContent)` whose `tool_call_id` was opened -> tool span close | ||
| - `StreamTaskMessageFull(ToolRequestContent)` (harnesses that emit tool calls as Full) -> opens a tool span; matching `Full(ToolResponseContent)` closes it | ||
| - `StreamTaskMessageStart(ReasoningContent)` + `StreamTaskMessageDone` -> reasoning span | ||
|
|
||
| `SpanTracer` is `SpanDeriver`'s consumer. You can inject a custom `SpanTracer` via `UnifiedEmitter(tracer=<instance>)` for advanced use or testing. | ||
|
|
||
| --- | ||
|
|
||
| ## Usage examples by channel | ||
|
|
||
| ### Sync ACP (pydantic-ai tap) | ||
|
|
||
| ```python | ||
| import agentex.lib.adk as adk | ||
| from agentex.lib.adk import UnifiedEmitter, convert_pydantic_ai_to_agentex_events | ||
|
|
||
| @acp.on_message_send | ||
| async def handle(params): | ||
| task_id = params.task.id | ||
| async with adk.tracing.span(trace_id=task_id, name="message", ...) as turn_span: | ||
| emitter = UnifiedEmitter( | ||
| task_id=task_id, | ||
| trace_id=task_id, | ||
| parent_span_id=turn_span.id if turn_span else None, | ||
| ) | ||
| tap = convert_pydantic_ai_to_agentex_events(pydantic_stream) | ||
| # wrap tap in a HarnessTurn then yield_turn, or yield directly: | ||
| async for event in tap: | ||
| yield event | ||
| ``` | ||
|
|
||
| For the pre-unified sync path the tap is still yielded directly; `UnifiedEmitter.yield_turn` is the forward-looking integration point when a `HarnessTurn` wrapper is available. | ||
|
|
||
| ### Async Temporal (auto-send) | ||
|
|
||
| ```python | ||
| from agentex.lib.adk import UnifiedEmitter | ||
|
|
||
| emitter = UnifiedEmitter( | ||
| task_id=task_id, | ||
| trace_id=task_id, | ||
| parent_span_id=parent_span_id, | ||
| ) | ||
| result = await emitter.auto_send_turn(turn, created_at=workflow.now()) | ||
| # result.final_text — last text segment | ||
| # result.usage — TurnUsage (tokens, cost, ...) | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice