Skip to content

Emit canonical transcript events from all workflow spawn modes - #273

Merged
jameswnl merged 6 commits into
mainfrom
issue-transcript-parity
Oct 1, 2026
Merged

jameswnl merged 6 commits into
mainfrom
issue-transcript-parity

Conversation

@jameswnl

@jameswnl jameswnl commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #272. Companion verification work: lightspeed-stack fork #52.

Summary

  • New executor/step/transcript_events.py: reconstructs canonical transcript events (tool_call / tool_result / thinking / result / error, {"ts", "type", "data"}) from the pydantic-ai message history. Data keys mirror the sandbox EventLogger contract (lightspeed-agentic-sandbox src/lightspeed_agentic/logging.py) — the canonical producer — so event types and data keys match across spawn modes.
  • DirectExecutor (spawn: none) run/run_stream/model-request paths emit canonical events; all failure paths emit error events (previously empty transcripts).
  • SubprocessExecutor child (spawn: local): agent path, model-request path, and main() failure path emit canonical events over the existing stdin/stdout protocol.
  • chat/runner._save_turn now reads canonical data keys (the previous flat-key reads matched no producer); tool results pair with pending calls in FIFO order so replayed names and synthesized call IDs match. Malformed or potentially truncated tool arguments and their paired results are omitted from provider history while the bounded audit events remain intact.
  • docs/workflow-transcript-contract.md: the cross-mode contract plus documented gaps for none/local — one aggregate result event per run, cost_usd: null, run-completion ts (pydantic-ai has no per-turn usage source). Streaming StreamEvents are explicitly out of scope.

Test plan

  • tests/unit/workflow/executor/test_transcript_events.py (new, 26 tests): converter contract (event types/order/shape/data keys/truncation/args serialization), normalize_transcript_events passthrough, end-to-end TestModel-driven emission through DirectExecutor.run and subprocess_child.main() (real agent loop calling a registered tool), and error events on both failure paths.
  • Updated shape-pinning tests in test_direct_executor.py / test_chat_workflow_runner.py to the canonical contract.
  • uv run pytest tests/unit -q: 2188 passed, 5 skipped. black --check and ruff check clean.

Review regressions cover the complete save-to-replay path for single and batched calls, including batches containing large arguments. Both subprocess execution paths append an error event when output-schema parsing fails; tests cover non-JSON, null, and successful JSON responses.

Mixed explicit/synthesized-ID history now removes explicit returns from the pending queue by matching ID, including out-of-order returns. The regression spans old and new turns. Integration fixtures use canonical events and verify provider replay identities and payloads.

jameswnl and others added 2 commits September 30, 2026 00:33
DirectExecutor (spawn: none) and SubprocessExecutor (spawn: local) now
emit the same per-event canonical transcript (tool_call / tool_result /
thinking / result / error) that the sandbox (spawn: ephemeral) already
produced, reconstructed from the pydantic-ai message history. Failed
runs emit an error event instead of an empty/summary transcript.

- new executor/step/transcript_events.py: message-history -> canonical
  event reconstruction; data keys mirror the sandbox EventLogger
  contract (lightspeed-agentic-sandbox src/lightspeed_agentic/logging.py)
- direct.py: _run_with_agent / run_stream / _run_model_request emit
  canonical events; failure paths emit error events
- subprocess_child.py: _run_with_agent / _run_model_request / main()
  failure path emit canonical events over the stdin/stdout protocol
- chat/runner.py: _save_turn reads canonical data keys; tool_result
  conversation metadata carries the tool name forward from the
  preceding tool_call event (canonical tool_result events have no name)
- docs/workflow-transcript-contract.md: per-mode contract + documented
  gaps (single aggregate result event, cost_usd null, completion-time
  ts) for none/local vs ephemeral

Companion verification work in lightspeed-stack fork issue #52.

Co-Authored-By: Claude <noreply@anthropic.com>
…tputs

Review follow-up on the parity change (PR #273):

- _save_turn omits tool_call_id from tool conversation metadata when the
  canonical event carries none, so _build_message_history synthesizes
  its call_<tool>_<n> fallback instead of replaying an empty id to the
  provider on multi-turn-with-tools history
- RetryPromptPart outputs truncate at MAX_EVENT_FIELD_LENGTH like other
  tool_result payloads

Co-Authored-By: Claude <noreply@anthropic.com>
jameswnl added a commit to jameswnl/lightspeed-stack that referenced this pull request Oct 1, 2026
Pin lightspeed-cloud-agents to the transcript-parity branch head
(lightspeed-core/lightspeed-cloud-agents#273, rev 91d48ab) and verify
on the stack side that the same workflow step yields the same canonical
transcript events across spawn modes none/local (end-to-end) and
ephemeral (contract level).

- bump lightspeed-cloud-agents pin to rev 91d48ab (canonical events
  from DirectExecutor/SubprocessExecutor; revert to branch=main once
  lightspeed-core#273 merges)
- integration tests: one-step and multi-step workflows through the real
  LocalWorkflowRunner + in-memory stores against a scripted mock LLM --
  none/local yield identical event sequences (tool_call, tool_result,
  result with the sandbox data-key contract), read via the public
  get_step_transcripts accessor; failed runs persist error events;
  verified RED against the pre-parity pin (6/9 failing)
- mock LLM server: opt-in scripted tool-call mode (Responses API
  function_call round-trip, unique call ids, defensive input guard)
  for deterministic cross-mode tool runs
- e2e: one-step none and ephemeral transcript assertions now pin the
  canonical event shape over real HTTP
- docs: workflow transcript contract + documented per-mode gaps in
  integration-architecture.md; demo script notes canonical jq
  projections

Co-Authored-By: Claude <noreply@anthropic.com>
Review follow-up on PR #273 (lightspeed-stack #58 blocking finding):

- _build_message_history: synthesize call ids that advance only on
  tool_call and pair each ToolReturnPart with the oldest unpaired
  call's id (FIFO). The counter previously advanced on both roles, so
  a call/result pair got call_<tool>_0 / call_<tool>_1 -- providers
  reject the history on the next /query/direct turn after any turn
  that ran a tool. Parallel calls pair in call order; out-of-order
  parallel results are paired in call order (best reconstruction from
  id-less canonical events).
- chat/runner._save_turn: pair tool_result conversation metadata with
  the oldest unpaired call's NAME (FIFO) instead of the last call's
  name, so parallel results keep their own tool names.
- transcript_events docstring: callers pass new_messages(), not
  all_messages().
- docs: consumer guidance for result events (sum across all result
  events, cost_usd null means unknown, final answer = last result).

Co-Authored-By: Claude <noreply@anthropic.com>

@beesarmy beesarmy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 91d48ab. The shared canonical event conversion is clear, but the new persistence path produces invalid conversation history after tool use, and one subprocess failure path does not satisfy the error-event contract. I reproduced the inline findings against this exact head using the existing virtual environment, exercising _save_turn -> _build_message_history with real pydantic-ai message parts and the subprocess model-request path with a stubbed model response. No external model calls were made; I did not rerun the full unit suite.

Comment on lines 651 to +653
metadata={
"tool_name": event.get("tool_name", ""),
"tool_call_id": event.get("tool_call_id", ""),
"tool_name": current_tool_name,
# tool_call_id omitted: canonical tool_result

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve tool call/result pairing before replaying the next turn

Omitting tool_call_id does not synthesize a matching fallback in the current _build_message_history: that function increments tool_call_counter for both calls and returns. Saving a single read_file call/result here and replaying the saved messages produces call_read_file_0 for the call and call_read_file_1 for its return, leaving an unanswered call and an unmatched return in the next provider request. Batched calls are also misidentified: call(a), call(b), result(a), result(b) saves both results with tool_name=b because current_tool_name only remembers the last call. This now affects ordinary spawn:none chat turns because this PR starts emitting and saving these events. Preserve a matching identity/name for each call and return (or fix replay pairing together with this change), and cover the complete save-to-replay path for single and multiple tool calls.

Comment on lines +622 to +627
args: Any = data.get("input", "")
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
pass

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Avoid replaying truncated transcript input as tool arguments

transcript_events_from_messages deliberately caps data.input at 2000 characters, so this JSONDecodeError fallback persists an incomplete JSON string as the actual tool-call arguments for subsequent turns. I reproduced this with write_file({content: 'x' * 2100}): after _save_turn and _build_message_history, the ToolCallPart.args is invalid JSON. Anthropic and OpenAI Responses adapters call args_as_dict() on historical tool calls, so mapping the next request can fail before any model call; other adapters receive corrupted arguments. Keep complete arguments in conversation persistence through a separate representation, or omit incomplete tool exchanges from provider history. The bounded audit transcript alone cannot serve as a lossless conversation history.

Comment on lines 383 to +387
parsed = _parse_content(content, output_schema)
parsed["input_tokens"] = input_tokens
parsed["output_tokens"] = output_tokens
parsed.setdefault(
"transcript",
[
{"role": "user", "content": user_content},
{"role": "assistant", "content": content or ""},
],
)
parsed["transcript"] = [
result_transcript_event(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Emit an error event for subprocess output parsing failures

_parse_content returns status='failed' rather than raising when a response is non-JSON (or null) and output_schema was requested. This path unconditionally overwrites transcript with a result event, so main() never enters the exception handler that adds error_transcript_event. With a model response of 'not JSON' and output_schema={'type': 'object'}, I reproduced a failed StepResult whose only event is result. The agent path has the same issue when it assigns transcript_events_from_messages after _parse_content. Append an error event when parsed.status is failed in both paths so transcript consumers see the failure and spawn:local meets the documented failure contract, as spawn:none does.

@beesarmy beesarmy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed b5a1902. The three original findings are addressed for the new canonical-only history: FIFO call/result IDs and names pair correctly, malformed or potentially truncated argument exchanges are excluded from provider replay, and both child execution paths append error events for parsing failures. The new save-to-replay and parsing regressions are useful.

Validation: 194 focused tests passed (test_direct_executor.py, test_transcript_events.py, test_chat_workflow_runner.py) against this exact checkout using the existing cloud-agents virtual environment. Stack's updated parity/mock suites also passed 25 tests against this source. No live sandbox or full unit suite was run.

One remaining issue in the pairing fix: the explicit-ID return branch never removes its call from pending_calls. Mixing supported explicit-ID history with the ID-less messages now persisted by _save_turn causes a later return to reuse an already-completed call's ID. Reproduction and fix guidance are inline.

Comment on lines +327 to +328
if metadata.get("tool_call_id"):
tool_call_id = metadata["tool_call_id"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Remove completed explicit-ID calls from the pending queue

Every tool_call is added to pending_calls, including calls with metadata.tool_call_id, but this explicit-ID result branch leaves its corresponding call queued. If prior history has an explicit-ID exchange and a later turn has the ID-less exchange saved by the new _save_turn, the later result consumes the completed old call. I reproduced two turns: old_tool call/return both use old-id; new_tool's call uses call_new_tool_1, but new_tool's return incorrectly uses old-id. Thus the supposedly repaired history again has a call without its matching result and assigns a new output to the wrong call. Explicit IDs are already supported by this adapter and its existing tests, so mixed history should work too. When honoring an explicit result ID, remove the matching pending entry by ID (rather than blindly popping, which would break out-of-order explicit returns), and add a mixed explicit/synthesized-ID multi-turn regression.

@beesarmy

beesarmy commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator

Coverage follow-up for head b5a1902:

  • Full unit suite: 2,186 passed, 5 skipped, run against this exact source checkout using the cloud-agents Python 3.14 environment with branch coverage enabled.
  • Combined line/branch coverage for the four changed runtime modules: 90%. This is measured module coverage, not whole-repository coverage.
Module Line coverage Branch coverage Added executable lines covered
transcript_events.py 100% 94% 44/44
subprocess_child.py 97% 85% 7/7
direct.py 91% 81% 12/13
chat/runner.py 92% 79% 16/21

Added-line counts are executable added lines compared with the PR base; comments/docstrings are excluded.

The explicit-ID tool-result branch (direct.py:328) has no test coverage, including in the full unit suite. That is exactly the branch implicated in my remaining mixed-history finding. Please add a multi-turn history test combining an explicit-ID exchange with a subsequent synthesized-ID exchange, plus explicit results arriving out of order, and assert both IDs and outputs.

Other uncovered new paths: malformed JSON tool input (chat/runner.py:635-637), parsed non-object input (639-640), and empty ThinkingPart text. Tests currently cover the large-input omission but do not exercise those distinct rejection paths.

One assertion limitation that line coverage does not reveal: the run_stream tests mock the stream and leave new_messages() without real tool-message parts, so they exercise the conversion call but assert only the final result event. A real streaming agent/tool run, or a stream fixture with concrete ToolCallPart/ToolReturnPart values, should assert the emitted canonical tool events. This is additional coverage guidance, not a separate runtime defect.

@beesarmy beesarmy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after the requested 30-minute follow-up review of head 1194ff0. All previously reported runtime findings are addressed. Explicit-ID returns now remove their matching pending call by ID, preserving mixed explicit/synthesized histories and out-of-order explicit returns; my original two-turn reproduction now yields matching IDs and preserves the correct outputs. The added regression cases exercise both return orders, and the integration fixtures now use canonical events with assertions on replay identities, arguments, and outputs.

Validation and coverage:

  • 206 focused unit/integration tests passed locally against this exact checkout with branch coverage enabled.
  • The formerly uncovered explicit-ID return branch and all executable statements in the new matching/removal block are covered. Both matching and nonmatching pending-ID comparisons were exercised.
  • The prior full unit coverage run on b5a1902 passed 2,186 tests with 5 skips; current-head CI is green for lint, unit tests, integration, e2e, and build. I did not repeat the full local suite after this small fix.
  • Stack's broader cloud-agents suites and mock tests passed 149 tests with 1 skip against this exact dependency source.

Non-blocking coverage follow-ups remain from the earlier coverage comment: malformed/non-object bounded arguments, empty thinking blocks, and streaming transcript assertions using concrete tool-message parts. The orphan explicit-result/no-matching-pending-call loop exit is also unexercised. These do not invalidate the reproduced fixes or approval. No live ephemeral sandbox was exercised locally.

@jameswnl
jameswnl merged commit 1788b17 into main Oct 1, 2026
6 checks passed
@jameswnl
jameswnl deleted the issue-transcript-parity branch October 1, 2026 06:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Emit canonical transcript events from all workflow spawn modes

2 participants