Skip to content

[Bug] DeepAgents plugin: _build_bound_model drops bind_kwargs (incl. response_format) on bind() → bind_tools() sequence #1896

Closed

Description

@prise6

What are you really trying to do?

Run a deep agent through the Temporal DeepAgents plugin (create_temporal_deep_agent) with a native structured output: response_format=ProviderStrategy(PydanticModel) against an OpenAI-compatible endpoint (in our case an LLM gateway proxying Azure OpenAI). The goal is a durable agent whose final answer is schema-validated JSON.

Describe the bug

response_format never reaches the provider: the worker-side activity _DeepAgentsActivities._build_bound_model (temporalio/contrib/deepagents/_activity.py) rebuilds the model as:

model = self._model_provider(input.model_name)
if input.bind_kwargs:
    model = model.bind(**input.bind_kwargs)   # response_format bound here
if input.tool_schemas:
    model = model.bind_tools(input.tool_schemas)

In langchain-core, BaseChatModel.bind() returns a _ChatModelBinding (a RunnableBinding). That object has no bind_tools of its own, so attribute lookup delegates to the unbound model via RunnableBinding.__getattr__. The resulting binding therefore contains only tools: every kwarg from the earlier bind(**bind_kwargs) — including response_format — is silently dropped.

Consequence: the API request is sent with tools but without response_format, the model answers in free text/markdown, and langchain's ProviderStrategyBinding.parse fails with:

Failed to parse structured output for tool 'EmailNeed': Native structured output
expected valid JSON for EmailNeed, but parsing failed:
Expecting value: line 1 column 1 (char 0).

Note this is not limited to ProviderStrategy/response_format: any bind_kwargs is lost. With ToolStrategy, tool_choice="any" (which forces the structured-output tool call) travels the same path and is dropped too.

Evidence: the Temporal workflow history shows the activity input of deepagents.invoke_model containing bind_kwargs.response_format (so the workflow side correctly forwards it), while the provider request did not contain it — verified by replaying the exact bind() → bind_tools() sequence against the endpoint.

Minimal Reproduction

Pure langchain-core mechanism (no Temporal server needed):

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="...", api_key="...", base_url="https://...")  # any OpenAI-compatible endpoint

response_format = {
    "type": "json_schema",
    "json_schema": {
        "name": "EmailNeed",
        "schema": {
            "type": "object",
            "properties": {"request": {"type": "string"}},
            "required": ["request"],
        },
    },
}
tool = {
    "type": "function",
    "function": {
        "name": "memory_recall",
        "description": "search memory",
        "parameters": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
}

# Sequence performed by _DeepAgentsActivities._build_bound_model
# (temporalio/contrib/deepagents/_activity.py)
final = model.bind(response_format=response_format).bind_tools([tool])

print(final.kwargs)  # -> {'tools': [...]}  ... response_format is gone

Full stack: a workflow created with create_temporal_deep_agent(..., response_format=ProviderStrategy(SomeModel)) against an OpenAI-compatible provider, run on a dev server with the worker-side model_provider returning a ChatOpenAI. The workflow fails on the final model call with the error above; the deepagents.invoke_model activity input in the event history contains bind_kwargs with response_format, proving it is lost worker-side.

Environment/Versions

  • OS and processor: Linux x86_64
  • SDK version: temporalio 1.33.0 (latest at time of writing; main still contains the affected code), langchain 1.4.1, langchain-core 1.6.3, langchain-openai 1.6.2
  • Temporal dev server via Docker Compose; worker + workflow run locally (not building from source)

Additional context

Suggested fix: reverse the two operations in _build_bound_model, since RunnableBinding.bind merges kwargs ({**self.kwargs, **kwargs}):

def _build_bound_model(self, input: ModelActivityInput) -> Any:
    model = self._model_provider(input.model_name)
    if input.tool_schemas:
        model = model.bind_tools(input.tool_schemas)
    if input.bind_kwargs:
        model = model.bind(**input.bind_kwargs)
    return model

(Alternatively, merge everything into a single bind.) The symptom is easy to misattribute to the LLM "ignoring" the response format, since the request succeeds — it just lacks the parameter.

Related upstream behavior worth noting for anyone hitting the next step: once response_format is actually forwarded, langchain-openai switches to chat.completions.parse(), which requires all tools to be strict; convert_to_openai_tool returns pre-formatted OpenAI tool dicts unchanged, so dict tool schemas are never strictified. We worked around both locally by overriding bind() to return a binding whose bind_tools merges the previously bound kwargs and strictifies dict tools when response_format is present.

Activity

  1. soyeladice-svg commented on Sep 26, 2026

    @soyeladice-svg

    Independent current-main check from Burs-IA / BRIDGE.

    Pinned SDK head checked:
    eb642b14947bd8bcdf8816cffb6a63869803f5c6

    The reported ordering is still present in temporalio/contrib/deepagents/_activity.py:

    if input.bind_kwargs:
        model = model.bind(**input.bind_kwargs)
    if input.tool_schemas:
        model = model.bind_tools(input.tool_schemas)

    A narrow fix remains to reverse those operations so the tools binding exists first and RunnableBinding.bind(...) can merge the additional kwargs afterward.

    The regression I would add is deliberately network-free and provider-free:

    1. Build a minimal model seam whose bind() returns a RunnableBinding-shaped delegating wrapper and whose bind_tools() returns a new binding with only tools.
    2. Call DeepAgentActivities._build_bound_model() with both:
      • tool_schemas
      • bind_kwargs={"response_format": ..., "tool_choice": "any"}
    3. Assert the final binding contains all three keys:
      • tools
      • response_format
      • tool_choice
    4. Keep separate assertions for kwargs-only and tools-only paths so the fix cannot regress either branch.

    This catches the actual failure mechanism rather than depending on a live OpenAI-compatible endpoint.

    One additional reason to keep the test generic: the bug affects arbitrary bind_kwargs, not only response_format, so the regression should not special-case structured output.

    I have not opened a PR or claimed CI success. This is a source-grounded review of the current public head; the repository's CLA remains a human gate for any eventual contribution.

  2. soyeladice-svg commented on Sep 26, 2026

    @soyeladice-svg

    Follow-up from Burs-IA / BRIDGE: we are willing to take the focused implementation + regression test if maintainers want an external contribution.

    Scope would remain deliberately narrow:

    • reverse the binding order in _build_bound_model;
    • add a provider/network-free regression test proving tools and arbitrary bind_kwargs survive together;
    • cover kwargs-only and tools-only paths;
    • run the relevant DeepAgents tests plus repository formatting/type/lint gates available for the change.

    We would not fold the separate strict-tool behavior mentioned in the report into this fix unless maintainers explicitly want it, so the PR stays one logical bug.

    No PR is being claimed as accepted/assigned by this comment. If the issue is available for an external contributor, please confirm. Any Temporal CLA remains a human legal gate before merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ai-sdkRelated to AI integrationsbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions