V1.4#6
Conversation
…e_schema wins (langchain-ai#37223) ## Problem `_resolve_schemas` collected middleware state schemas into a `set[type]`, making field-conflict resolution non-deterministic. When two schemas declare the same field with different annotations (e.g. `DeltaChannel` vs `BinaryOperatorAggregate` on `messages`), the winner depended on set iteration order — effectively random across runs. This forced callers to work around it with post-compilation patches like: ```python if not isinstance(agent.channels.get("messages"), DeltaChannel): agent.channels["messages"] = DeltaChannel(...) ``` ## Fix Change `_resolve_schemas` to accept `list[type]` and build it as: ```python state_schemas = [*(m.state_schema for m in middleware), base_state] ``` `base_state` (the caller's explicit `state_schema`, or `AgentState` by default) goes **last** and wins any conflict. Middleware schemas precede it in registration order. This means callers can now pass `state_schema=MyCustomState` and be guaranteed it overrides any conflicting middleware annotation — no post-compilation patching needed. ## Test plan - All 9 existing `test_state_schema.py` tests pass - Updated the two direct `_resolve_schemas({...})` call sites in tests to use lists
Bumps `langchain` from `1.3.0a1` → `1.3.0a2`.
Bumps `langchain` from `1.3.0a1` → `1.3.0a2`. Also locks `langgraph==1.2.0a7` / `langgraph-checkpoint==4.1.0a4` and adds `prerelease = "allow"` to `[tool.uv]` so the resolver picks up transitive alpha deps automatically.
Bumps `langchain-core` from `1.4.0a2` → `1.4.0`
Policy Check Failed✗ 3/3 policy checks failed: • Need 2 more approval(s) (0/2) — comment LGTM or approve via review To merge this PR:
|
PR SummaryWhat Changed
Key Changes by AreaStreaming API: Unified Event Protocol: Compat Bridge: Added multi-block tracking with stable indices for interleaved content blocks; added Agent Streaming: Added Type Safety: Added Files Changed
Review Focus Areas
ArchitectureDesign Decisions: The v3 protocol uses explicit delta types instead of raw content blocks to enable richer streaming projections ( Scalability & Extensibility: The Risks:
Merge StatusNOT MERGEABLE — PR Score 32/100, below threshold (50)
|
| for key, value in delta.items(): | ||
| if key in ("type", "data") or value is None: | ||
| continue | ||
| if key == "extras" and isinstance(value, dict): | ||
| state["extras"] = {**(state.get("extras") or {}), **value} |
There was a problem hiding this comment.
Using dict.update() for input_token_details and output_token_details overwrites per-key counts across chunks instead of summing them, so nested usage totals become incorrect; sum numeric subkeys when merging.
Suggested fix
if detail_key not in current:
current[detail_key] = {}
for subkey, subvalue in delta[detail_key].items():
current[detail_key][subkey] = current[detail_key].get(subkey, 0) + subvaluePrompt for AI assistance
Copy the prompt below and paste it into ChatGPT, Claude, or any LLM:
You are an expert python developer with deep knowledge of security, performance, and best practices.
### Context
File: libs/core/langchain_core/language_models/_compat_bridge.py
Lines: 329-333
Issue Type: functional-high
Severity: high
Issue Description:
Using `dict.update()` for `input_token_details` and `output_token_details` overwrites per-key counts across chunks instead of summing them, so nested usage totals become incorrect; sum numeric subkeys when merging.
Current Code:
if detail_key not in current:
current[detail_key] = {}
current[detail_key].update(delta[detail_key])
---
### Instructions
1. Fix the issue described above
2. Maintain the exact indentation and code style from the original
3. Follow python best practices and language-specific idioms
4. Ensure the fix addresses the root cause, not just the symptoms
5. Add brief inline comments explaining the fix if needed
### Constraints
- Do not change functionality beyond fixing the identified issue
- Preserve existing variable names and function signatures unless they are part of the problem
- Ensure the fix is production-ready
---
| def _merge_block_delta_into_store( | ||
| store: dict[int, dict[str, Any]], | ||
| idx: int, | ||
| fields: dict[str, Any], | ||
| ) -> None: | ||
| """Shallow-merge a block-delta snapshot into an indexed chunk store.""" | ||
| existing = store.get(idx, {}) | ||
| for key, value in fields.items(): | ||
| if value is not None: | ||
| existing[key] = value | ||
| store[idx] = existing |
There was a problem hiding this comment.
The new block-delta path overwrites previously accumulated tool-call args chunks, so partial JSON fragments are lost; append args like _merge_chunk_into_store does or special-case that field during merge.
Suggested fix
def _merge_block_delta_into_store(
store: dict[int, dict[str, Any]],
idx: int,
fields: dict[str, Any],
) -> None:
"""Merge a block-delta snapshot into an indexed chunk store."""
existing = store.get(idx, {})
for key, value in fields.items():
if value is None:
continue
if key == "args":
existing[key] = existing.get(key, "") + value
else:
existing[key] = value
store[idx] = existingPrompt for AI assistance
Copy the prompt below and paste it into ChatGPT, Claude, or any LLM:
You are an expert python developer with deep knowledge of security, performance, and best practices.
### Context
File: libs/core/langchain_core/language_models/chat_model_stream.py
Lines: 68-78
Issue Type: functional-high
Severity: high
Issue Description:
The new `block-delta` path overwrites previously accumulated tool-call `args` chunks, so partial JSON fragments are lost; append `args` like `_merge_chunk_into_store` does or special-case that field during merge.
Current Code:
def _merge_block_delta_into_store(
store: dict[int, dict[str, Any]],
idx: int,
fields: dict[str, Any],
) -> None:
"""Shallow-merge a block-delta snapshot into an indexed chunk store."""
existing = store.get(idx, {})
for key, value in fields.items():
if value is not None:
existing[key] = value
store[idx] = existing
---
### Instructions
1. Fix the issue described above
2. Maintain the exact indentation and code style from the original
3. Follow python best practices and language-specific idioms
4. Ensure the fix addresses the root cause, not just the symptoms
5. Add brief inline comments explaining the fix if needed
### Constraints
- Do not change functionality beyond fixing the identified issue
- Preserve existing variable names and function signatures unless they are part of the problem
- Ensure the fix is production-ready
---
| dtype = delta.get("type") | ||
| if dtype == "text-delta": | ||
| accum["text"] = accum.get("text", "") + delta.get("text", "") | ||
| elif dtype == "reasoning-delta": | ||
| accum["reasoning"] = accum.get("reasoning", "") + delta.get("reasoning", "") | ||
| elif dtype == "data-delta": | ||
| accum["data"] = accum.get("data", "") + delta.get("data", "") | ||
| elif dtype == "block-delta": | ||
| fields = delta.get("fields") | ||
| if not isinstance(fields, dict): | ||
| return | ||
| btype = fields.get("type") | ||
| if btype not in _DELTAABLE_TYPES: | ||
| return | ||
| accum.update({k: v for k, v in fields.items() if v is not None}) |
There was a problem hiding this comment.
Tool-call chunk deltas are no longer accumulated into args, id, and name; add explicit handling for tool_call_chunk and server_tool_call_chunk delta shapes so finish validation still works.
Suggested fix
dtype = delta.get("type")
if dtype == "text-delta":
accum["text"] = accum.get("text", "") + delta.get("text", "")
elif dtype == "reasoning-delta":
accum["reasoning"] = accum.get("reasoning", "") + delta.get("reasoning", "")
elif dtype == "data-delta":
accum["data"] = accum.get("data", "") + delta.get("data", "")
elif dtype in {"tool_call_chunk-delta", "server_tool_call_chunk-delta"}:
accum["args"] = accum.get("args", "") + (delta.get("args") or "")
if delta.get("id") is not None:
accum["id"] = delta["id"]
if delta.get("name") is not None:
accum["name"] = delta["name"]
elif dtype == "block-delta":
fields = delta.get("fields")
if not isinstance(fields, dict):
return
btype = fields.get("type")
if btype not in _DELTAABLE_TYPES:
return
if btype in {"tool_call_chunk", "server_tool_call_chunk"}:
accum["args"] = accum.get("args", "") + (fields.get("args") or "")
if fields.get("id") is not None:
accum["id"] = fields["id"]
if fields.get("name") is not None:
accum["name"] = fields["name"]
else:
accum.update({k: v for k, v in fields.items() if v is not None})Prompt for AI assistance
Copy the prompt below and paste it into ChatGPT, Claude, or any LLM:
You are an expert python developer with deep knowledge of security, performance, and best practices.
### Context
File: libs/standard-tests/langchain_tests/utils/stream_lifecycle.py
Lines: 172-186
Issue Type: functional-high
Severity: high
Issue Description:
Tool-call chunk deltas are no longer accumulated into `args`, `id`, and `name`; add explicit handling for `tool_call_chunk` and `server_tool_call_chunk` delta shapes so finish validation still works.
Current Code:
dtype = delta.get("type")
if dtype == "text-delta":
accum["text"] = accum.get("text", "") + delta.get("text", "")
elif dtype == "reasoning-delta":
accum["reasoning"] = accum.get("reasoning", "") + delta.get("reasoning", "")
elif dtype == "data-delta":
accum["data"] = accum.get("data", "") + delta.get("data", "")
elif dtype == "block-delta":
fields = delta.get("fields")
if not isinstance(fields, dict):
return
btype = fields.get("type")
if btype not in _DELTAABLE_TYPES:
return
accum.update({k: v for k, v in fields.items() if v is not None})
---
### Instructions
1. Fix the issue described above
2. Maintain the exact indentation and code style from the original
3. Follow python best practices and language-specific idioms
4. Ensure the fix addresses the root cause, not just the symptoms
5. Add brief inline comments explaining the fix if needed
### Constraints
- Do not change functionality beyond fixing the identified issue
- Preserve existing variable names and function signatures unless they are part of the problem
- Ensure the fix is production-ready
---
Security Scan Summary
No critical security issues detected Scan completed in 79.0sSecurity scan powered by Codity.ai |
License Compliance Scan
All licenses are low-risk and compliant Powered by Codity.ai · Docs |
Code Quality Report — test-org-codity/langchain · PR #6Scanned: 2026-05-21 13:07 UTC | Score: 17/100 | Provider: github Executive Summary
Top Findings[CQ-LLM-004]
|
| File | Critical | High | Medium | Low | Total |
|---|---|---|---|---|---|
libs/core/langchain_core/callbacks/base.py |
0 | 0 | 0 | 1 | 1 |
libs/core/langchain_core/callbacks/manager.py |
0 | 0 | 0 | 1 | 1 |
libs/core/langchain_core/language_models/_compat_bridge.py |
0 | 0 | 3 | 13 | 16 |
libs/core/langchain_core/language_models/chat_model_stream.py |
0 | 0 | 0 | 21 | 21 |
libs/core/langchain_core/language_models/chat_models.py |
0 | 0 | 0 | 12 | 12 |
libs/core/langchain_core/runnables/base.py |
0 | 0 | 0 | 13 | 13 |
libs/core/tests/unit_tests/language_models/test_chat_model_streamer.py |
0 | 0 | 0 | 7 | 7 |
libs/core/tests/unit_tests/language_models/test_chat_model_v3_stream.py |
0 | 0 | 0 | 12 | 12 |
libs/core/tests/unit_tests/language_models/test_compat_bridge.py |
0 | 0 | 0 | 37 | 37 |
libs/core/tests/unit_tests/runnables/test_runnable_events_v3.py |
0 | 0 | 0 | 2 | 2 |
libs/core/uv.lock |
0 | 0 | 0 | 4 | 4 |
libs/langchain/langchain_classic/chat_models/base.py |
0 | 0 | 0 | 1 | 1 |
libs/langchain/uv.lock |
0 | 0 | 0 | 4 | 4 |
libs/langchain_v1/langchain/chat_models/base.py |
0 | 0 | 0 | 1 | 1 |
libs/langchain_v1/tests/unit_tests/agents/test_agent_streaming.py |
0 | 0 | 0 | 10 | 10 |
libs/langchain_v1/tests/unit_tests/agents/test_state_schema.py |
0 | 0 | 0 | 3 | 3 |
libs/langchain_v1/uv.lock |
0 | 0 | 0 | 16 | 16 |
libs/partners/anthropic/tests/unit_tests/test_chat_models.py |
0 | 0 | 0 | 1 | 1 |
libs/partners/anthropic/uv.lock |
0 | 0 | 0 | 4 | 4 |
libs/partners/openai/uv.lock |
0 | 0 | 0 | 12 | 12 |
libs/standard-tests/langchain_tests/utils/stream_lifecycle.py |
0 | 0 | 1 | 0 | 1 |
libs/standard-tests/uv.lock |
0 | 0 | 0 | 4 | 4 |
Recommendations
- Run automated tests after applying fixes to verify no regressions.
Greptile SummaryThis PR introduces the
Confidence Score: 3/5The core streaming refactor is logically sound but removes a previously public beta API without a migration path, which will break callers on upgrade. The removal of
Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant BaseChatModel
participant _chat_model_stream_v3
participant chunks_to_events
participant ChatModelStream
Caller->>BaseChatModel: "stream_events(input, version="v3")"
BaseChatModel->>_chat_model_stream_v3: _chat_model_stream_v3(input, config)
_chat_model_stream_v3->>ChatModelStream: ChatModelStream()
_chat_model_stream_v3->>_chat_model_stream_v3: on_chat_model_start → set_message_id(run_id)
_chat_model_stream_v3-->>Caller: return ChatModelStream (lazy)
Note over Caller,ChatModelStream: Caller iterates stream.text / stream.output
Caller->>ChatModelStream: pump_one()
ChatModelStream->>chunks_to_events: _stream() chunks
chunks_to_events->>chunks_to_events: build blocks dict (parallel tracking)
chunks_to_events-->>ChatModelStream: message-start event
ChatModelStream->>ChatModelStream: _push_message_start
chunks_to_events-->>ChatModelStream: content-block-start event
chunks_to_events-->>ChatModelStream: content-block-delta event
ChatModelStream->>ChatModelStream: _push_content_block_delta
chunks_to_events-->>ChatModelStream: content-block-finish event
chunks_to_events-->>ChatModelStream: message-finish event
ChatModelStream->>ChatModelStream: _finish → assemble AIMessage
ChatModelStream-->>Caller: stream.output (AIMessage)
Reviews (1): Last reviewed commit: "chore(langchain): drop `prerelease = "al..." | Re-trigger Greptile |
| @@ -969,54 +971,30 @@ async def astream( | |||
| LLMResult(generations=[[generation]]), | |||
There was a problem hiding this comment.
Breaking removal of
stream_v2 / astream_v2 without a deprecation shim
stream_v2() and astream_v2() were @beta() public methods on BaseChatModel. This PR removes them entirely and routes callers to the new stream_events(version="v3") / astream_events(version="v3") entry points. Any downstream code that currently calls model.stream_v2(...), model.bind_tools([...]).stream_v2(...), or tests using these names will raise AttributeError immediately after upgrading to 1.4.0, with no deprecation warning and no migration period. Beta APIs can break, but silent removal with no shim makes the upgrade harder to diagnose. A one-line alias (or at least a DeprecationWarning re-routing call) would let callers discover the replacement without an unhandled AttributeError.
| if _should_emit_delta(block): | ||
| wire_idx, current = blocks[key] | ||
| is_block_delta = block.get("type") in ( | ||
| "tool_call_chunk", | ||
| "server_tool_call_chunk", | ||
| ) | ||
| delta_source = current if is_block_delta else block | ||
| yield ContentBlockDeltaData( | ||
| event="content-block-delta", | ||
| index=open_wire_idx, | ||
| content_block=_to_protocol_block(block), | ||
| index=wire_idx, | ||
| delta=_to_content_delta(delta_source or block), | ||
| ) |
There was a problem hiding this comment.
Tool-call delta events carry cumulative state, not incremental chunks
For tool_call_chunk and server_tool_call_chunk blocks, delta_source = current (the fully accumulated block state), so every content-block-delta emits fields.args equal to the total concatenated args up to that point rather than just the new bytes from the incoming block. A consumer that appends each delta's args field to build the final tool call will produce doubled content. _push_content_block_delta's block-delta handler calls _merge_block_delta_into_store which overwrites fields on each delta, so it converges to the correct state, but external SDK consumers following the naive "concatenate each delta" pattern will produce incorrect args. Additionally, delta_source or block is always equivalent to delta_source because delta_source is always assigned a non-None dict (current or block) — the or block fallback is dead code.
| start_data = MessageStartData(event="message-start", role="ai", id="") | ||
| resolved_id = message_id if message_id is not None else getattr(msg, "id", None) | ||
| if resolved_id: | ||
| start_data["message_id"] = resolved_id | ||
| start_data["id"] = resolved_id |
There was a problem hiding this comment.
message-start always emits id: "" when no message ID is available
Initialising MessageStartData with id="" means the message-start event always contains the id key, even when no provider message ID was resolved. Previously the field was absent. Consumers that distinguish "has an ID" from "no ID" via "id" in event (rather than if event.get("id")) will now always see the key, treating the empty string as a valid ID. The _push_message_start guard (if message_id:) correctly ignores the empty string internally, but external consumers of the raw event may not apply the same guard. Omitting the field when no ID is available would match the previous protocol contract.
| start_data = MessageStartData(event="message-start", role="ai", id="") | |
| resolved_id = message_id if message_id is not None else getattr(msg, "id", None) | |
| if resolved_id: | |
| start_data["message_id"] = resolved_id | |
| start_data["id"] = resolved_id | |
| start_data = MessageStartData(event="message-start", role="ai") | |
| resolved_id = message_id if message_id is not None else getattr(msg, "id", None) | |
| if resolved_id: | |
| start_data["id"] = resolved_id |
Fixes #
Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview
If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!
Thank you for contributing to LangChain! Follow these steps to have your pull request considered as ready for review.
Fixes #xxline at the top is required for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge.make format,make lintandmake testfrom the root of the package(s) you've modified.Additional guidelines:
uv.lockfiles or add dependencies topyproject.tomlfiles (even optional ones) unless you have explicit permission to do so by a maintainer.Social handles (optional)
Twitter: @
LinkedIn: https://linkedin.com/in/