Skip to content

feat(aws-strands): forward RunAgentInput.context to the model in the TypeScript bridge - #2619

Merged
ranst91 merged 1 commit into
mainfrom
claude/epic-goldwasser-43cd41
Sep 4, 2026
Merged

feat(aws-strands): forward RunAgentInput.context to the model in the TypeScript bridge#2619
ranst91 merged 1 commit into
mainfrom
claude/epic-goldwasser-43cd41

Conversation

@ranst91

@ranst91 ranst91 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Brings the TypeScript AWS Strands bridge to parity with the Python bridge on forwarding RunAgentInput.context to the model.

Until now the TypeScript bridge handed context to tools and hooks (buildContextExtras) and to the A2UI subagent, but nothing put it in front of the model, so whatever the application published through useCopilotReadable was invisible to the LLM. Python has done this for a while; this PR implements the same contract in TypeScript, matching Python's observable behaviour rather than its plumbing.

What the model sees

The context entries are rendered as one text block:

Context provided by the application:
- description: value
- description: value

The A2UI component-schema entry is excluded through the shared toolkit's splitA2UISchemaContext, because the generate_a2ui path already carries it. The block is shown to the model for exactly one call and is never persisted: it does not reach agent.messages after the call, a MESSAGES_SNAPSHOT, or the session store.

Placement mirrors Python case for case:

  • normally, the block is inserted as its own user message immediately before the latest user turn, so that turn stays byte-identical for model routers and fixtures that key off it;
  • when the latest user turn carries a tool result, the block is prepended into that turn's content (a separate user message between a toolUse and its toolResult is a shape providers reject);
  • when there is no user turn at all, the block is appended.

How it is carried

  • model-context.ts (new): block rendering, the request-scoped carrier, the BeforeModelCallEvent / AfterModelCallEvent hook pair, restore, and the orchestrator walkers.
  • The carrier is AsyncLocalStorage, the Node analogue of Python's ContextVar. It is entered around each pull of the Strands stream (pullWithModelContext), exactly as Python's _stream_with_model_context sets and resets its token around each __anext__, so two runs in flight in one process cannot see each other's block. Covered by an interleaved two-thread test.
  • The mutation is recorded per agent in a WeakMap and undone from the after-hook on the normal path and from both run loops' teardown finally on the cancellation path, where the after-hook never fires. The teardown restore runs before the stream is returned, because returning the Strands stream runs the after-invocation hooks and a session manager snapshots agent.messages from there; a restore after the drain would come too late for the store. A session-manager cancellation test covers that ordering. The restore runs before the session manager can save: the SDK runs AfterModelCallEvent callbacks in reverse registration order, the bridge registers its pair after the session manager (at first run rather than at construction), and the assistant message is only appended, and so only persisted, after the after-hooks have returned.
  • Single-agent loop: the hook pair is installed once per agent; a non-empty block on an agent with no hook registry ends the run with a RUN_ERROR, as in Python.
  • Orchestrator loop: the hook pair goes on every reachable leaf agent (AgentNode.agent, recursing through MultiAgentNode.orchestrator, depth-capped like _MAX_MULTIAGENT_NESTING). An orchestrator with no reachable leaf gets the block prefixed onto its prompt instead, which is Python's fallback too.

Also ported: A2UI render-guide exclusion

While comparing prompts I found a pre-existing drift in the same area. Once A2UI auto-injection replaces the middleware's render_a2ui proxy with generate_a2ui, Python drops the middleware's usage guide for that proxy from both the model context and the recovery subagent's context (_without_a2ui_render_guides). TypeScript had no equivalent. This PR adds withoutA2UIRenderGuides and applies it at both sites, so the two bridges produce the same prompt with A2UI on as well as off. The planA2UIInjection state build is extracted into buildA2UISubagentState for that.

Where the two bridges still cannot produce the same prompt

  1. Non-string context values. The wire types Context.value as a string, so these only arrive from lax callers. JSON.stringify and json.dumps agree on strings, integers, booleans and null. They disagree on objects and arrays (json.dumps inserts a space after , and :), integral floats (1.0 against 1) and non-ASCII text (json.dumps escapes it). Left to differ rather than emulating Python's serializer in TypeScript; noted on formatAguiContext.
  2. Orchestrator resume refusal. Python refuses the prompt-prefix fallback during an interrupt resume. The TypeScript orchestrator path has no resume arm at all (pre-existing), so there is no path on which to refuse.
  3. Tool-result-turn mutation shape (not prompt-visible). Python swaps the turn's content list and swaps the original back; TypeScript unshifts a TextBlock into the existing array and removes it by identity. The model sees the same bytes.

Tests

context-forwarding.test.ts (24 tests) mirrors tests/test_context_forwarding.py case for case at the real model boundary (a scripted model records the exact history the SDK handed it), then covers the arms Python leaves to other suites: the tool-result turn, cancellation mid-model-call (in memory and at the session manager's save point), two interleaved runs, the orchestrator leaf path and its prompt fallback, the refusal on an agent with no hook registry, and the render-guide exclusion.

  • integrations/aws-strands/typescript: pnpm test 77 files, 1635 tests passed; pnpm typecheck clean; pnpm build clean.
  • integrations/aws-strands/python: pytest tests/ -q 1270 passed, no Python file touched.

ARCHITECTURE.md gains a "Model context" bullet that applies to both bridges and records divergence 2.

@ranst91
ranst91 requested a review from a team as a code owner September 3, 2026 08:26
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Python Preview Packages

Version 0.0.0.dev1788528809 published to TestPyPI.

Warning: These packages are built from contributor code that may not yet have been vetted for correctness or security. Install at your own risk and do not use in production.

Install with uv

Add the TestPyPI index to your pyproject.toml:

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = true

Then install the packages you need:

# Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1788528809' --index testpypi

# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1788528809' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1788528809' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1788528809' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1788528809' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1788528809' --index testpypi

Install with pip

pip install \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  ag-ui-protocol==0.0.0.dev1788528809

Use --extra-index-url https://pypi.org/simple/ so pip can resolve
transitive dependencies (pydantic, fastapi, etc.) from real PyPI.


Commit: dff84e2

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@ag-ui/a2a-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a-middleware@2619

@ag-ui/a2ui-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-middleware@2619

@ag-ui/event-throttle-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/event-throttle-middleware@2619

@ag-ui/mcp-apps-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-apps-middleware@2619

@ag-ui/mcp-middleware

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mcp-middleware@2619

@ag-ui/a2a

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2a@2619

@ag-ui/adk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/adk@2619

@ag-ui/ag2

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/ag2@2619

@ag-ui/agno

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/agno@2619

@ag-ui/aws-strands

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/aws-strands@2619

@ag-ui/claude-agent-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-agent-sdk@2619

@ag-ui/claude-managed-agents

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/claude-managed-agents@2619

@ag-ui/crewai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/crewai@2619

@ag-ui/langchain

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langchain@2619

@ag-ui/langgraph

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/langgraph@2619

@ag-ui/llamaindex

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/llamaindex@2619

@ag-ui/mastra

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/mastra@2619

@ag-ui/pydantic-ai

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/pydantic-ai@2619

@ag-ui/vercel-ai-sdk

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/vercel-ai-sdk@2619

@ag-ui/watsonx

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/watsonx@2619

@ag-ui/a2ui-toolkit

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/a2ui-toolkit@2619

create-ag-ui-app

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/create-ag-ui-app@2619

@ag-ui/client

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/client@2619

@ag-ui/core

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/core@2619

@ag-ui/encoder

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/encoder@2619

@ag-ui/proto

pnpm add https://pkg.pr.new/ag-ui-protocol/ag-ui/@ag-ui/proto@2619

commit: a32b3d3

@ranst91
ranst91 force-pushed the claude/epic-goldwasser-43cd41 branch from 757b070 to bf0ed39 Compare September 3, 2026 14:13

@mme mme left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work on this, the design is really carefully thought through and the parity with the Python side is easy to follow. I ran a codex review over the diff and then checked its one finding against the Strands SDK source, and I think it's real. Details inline, it's a small reorder in two places.

A few things I checked independently that all hold up: the SDK guarantees the before/after model-call pair even when the model throws, so the "not restored" guard can't misfire on a retry; the model reads a clone of agent.messages after the before-hook runs, so the splice is visible; and the AsyncLocalStorage scoping around each pull does cover the hooks.

// the block it was shown would otherwise stay in `agent.messages` and
// be the next thing persisted. Idempotent when the hook already ran.
// Python restores from the same teardown `finally`.
restoreTransientModelContext(strandsAgent);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this restore needs to happen before agentStream.return() rather than after.

Returning the Strands stream isn't a silent close. In SDK 1.8.0 the stream's own finally runs the AfterInvocationEvent callbacks even on consumer break (the comment in stream() says exactly that), and the session manager's default trigger is saveLatestOn: 'invocation', so it writes a full snapshot of agent.messages right there, while the context block is still in the list. The in-memory removal on this line runs afterwards and can't undo a snapshot that's already in storage, so the next restore of that session replays the app context as a real user turn.

1.1.0 (what the lockfile pins) is a bit different: it yields the AfterInvocationEvent from inside a finally, but it also appends a "Cancelled by user" message from that same finally, which fires MessageAddedEvent and saves under the per-message strategy. The package accepts >=1.1.0 so users will hit 1.8.0 behaviour anyway.

Moving restoreTransientModelContext(strandsAgent) above the .return() is safe: the after-hook doesn't fire on consumer break, and the restore is idempotent regardless. The cancellation case in context-forwarding.test.ts would be a natural place to assert on what a session manager sees at AfterInvocationEvent.

// A leaf abandoned mid-model-call never reached its after-hook; take
// the block back off every hooked leaf, as Python's
// `_restore_orchestrator_context` does from the same teardown.
restoreOrchestratorContext(this._orchestrator);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same ordering point as the single-agent teardown above: each leaf's stream runs its after-invocation callbacks during orchestratorStream.return(), so this should go before the .return() so a session manager on a leaf doesn't snapshot the block.

…TypeScript bridge

The Python bridge already renders the application's context entries as a
text block and shows that block to the model for exactly one call, without
ever persisting it. The TypeScript bridge handed the same context to tools,
hooks and the A2UI subagent, but never to the model, so anything the app
published through useCopilotReadable was invisible to the LLM.

This brings the TypeScript bridge to the same observable behaviour:

- The block is rendered byte for byte as Python renders it, with the A2UI
  component-schema entry excluded through the shared toolkit split.
- A BeforeModelCallEvent hook splices the block into agent.messages
  immediately before the latest user turn, into that turn's content when it
  carries a tool result, or appended when there is no user turn at all. The
  paired AfterModelCallEvent hook splices it back out, and the run-loop
  teardown restores again for the cancellation path.
- The block is carried in an AsyncLocalStorage set around each pull of the
  Strands stream, the direct analogue of Python's ContextVar, so concurrent
  runs cannot see each other's context.
- The orchestrator path installs the hook on every reachable leaf agent and
  falls back to prefixing the prompt when none is reachable, as Python does.
- The middleware's usage guide for a render tool that A2UI auto-injection
  replaced is dropped from both the model block and the recovery subagent's
  context, closing a prompt-parity gap the TypeScript bridge had.

A run that carries context on an agent with no hook registry ends with a
RUN_ERROR, matching Python. The shared scripted stub in the test helpers
gains an inert addHook so existing stub-driven tests keep their behaviour.
@ranst91
ranst91 force-pushed the claude/epic-goldwasser-43cd41 branch from bf0ed39 to a32b3d3 Compare September 4, 2026 13:33
@mme
mme self-requested a review September 4, 2026 14:05
@ranst91
ranst91 merged commit f77cc22 into main Sep 4, 2026
40 checks passed
@ranst91
ranst91 deleted the claude/epic-goldwasser-43cd41 branch September 4, 2026 14:06
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.

2 participants