Skip to content

Conversation

sydney-runkle
Copy link
Collaborator

@sydney-runkle sydney-runkle commented Sep 15, 2025

Main changes / new features

Better support for parallel tool calls

  1. Support for multiple tool calls requiring human input
  2. Support for combination of tool calls requiring human input + those that are auto-approved
  3. Support structured output w/ tool calls requiring human input
  4. Support structured output w/ standard tool calls

Enhanced action request / response primitives

Simplified the primitives we use for action responses -- accept and reject patterns. edit is rolled into accept w/ edits.

These primitives have no notion of tool calls (no ids etc) so we can generically use them for HITL patterns independent of tool call approvals.

Shortcut for allowed actions

Adds a shortcut where tool config can be specified as a bool, meaning "all actions allowed"

HumanInTheLoopMiddleware(tool_configs={"expensive_tool": True})

A few design decisions here

  • We only raise one interrupt w/ all HumanInterrupts, currently we won't be able to execute all tools until all of these are resolved. This isn't super blocking bc we can't re-invoke the model until all tools have finished execution. That being said, if you have a long running auto-approved tool, this could slow things down.
  • We populate the args for an action request w/ tool_name and tool_args and expect the same for edits.
  • allowed_actions is now represented as a list to be consistent with most other access control patterns

TODO

  • need to do a big docs pass
  • in another PR I'd like to refactor testing to have one file for each prebuilt middleware :)
  • work w/ applied AI team to ensure compat w/ agent inbox long term

Copy link

vercel bot commented Sep 15, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Preview Comments Updated (UTC)
langchain Ignored Ignored Preview Sep 17, 2025 3:40pm

@mdrxy mdrxy added the langchain Related to the package `langchain` label Sep 15, 2025
@eyurtsev
Copy link
Collaborator

We have to inject tool args ahead of time which is quite messy, we could instead refactor to pass state through (@eyurtsev did this at some point)

If this works for now, it's probably fine? Behavior is correct as far as an end user is concerned. We could add a TODO for this refactor (and potentially apply the patch I made before for ToolNode).

Do we want to enforce that HumanResponses are mapped 1:1 to a HumanInterrupt / tool call? Right now we rely on corresponding order which is quite error prone. We could map via tool call ids?

Is the tool call id part of the human interrupt? if so any reason not to do it?

I suspect even if we disable parallel tool calling, this could be an issue in workflows that use multiple agents in parallel?

@sydney-runkle
Copy link
Collaborator Author

If this works for now, it's probably fine? Behavior is correct as far as an end user is concerned. We could add a TODO for this refactor (and potentially apply the patch I made before for ToolNode).

Sounds great

@sydney-runkle
Copy link
Collaborator Author

sydney-runkle commented Sep 16, 2025

Pasting here, doesn't belong in description anymore:

Main question here is what we do with multiple tool calls and how we send them to tool node:

If we use Send:

  • interrupts within tools work as expected bc we kick off X ToolNodes in parallel where X is the number of pending tools
  • We have to inject tool args ahead of time which is quite messy, we could instead refactor to pass state through (@eyurtsev did this at some point)

If we don't use Send

  • interrupts within tools don't work as expected :(

  • We need to modify logic in ToolNode to filter through tool calls and only execute pending ones

    I think the value of interrupts working as expected is high, so perhaps should just refactor injection.

Will open an issue advocating for revisiting this

Copy link
Collaborator

@eyurtsev eyurtsev left a comment

Choose a reason for hiding this comment

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

Flash review, looking at the code

… structured output (#32980)

This enables parallel tool calling w/ a combo of
1. Standard and structured response tool calls
2. Deferred (requiring human approval / edits) tool calls and structured
response tool calls

Hard to unit test w/ HITL right now end to end, so here's a repro of
things working w/ an integration test:

```py
from pydantic import BaseModel, Field
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage
from langgraph.types import Command
from langgraph.checkpoint.memory import InMemorySaver
from langchain.agents.middleware_agent import create_agent
from langchain.agents.middleware.human_in_the_loop import HumanInTheLoopMiddleware
from langchain_openai import ChatOpenAI


class WeatherBaseModel(BaseModel):
    temperature: float = Field(description="Temperature in fahrenheit")
    condition: str = Field(description="Weather condition")


@tool
def add_numbers(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b


model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
checkpointer = InMemorySaver()

agent = create_agent(
    model=model,
    tools=[add_numbers],
    response_format=WeatherBaseModel,
    middleware=[HumanInTheLoopMiddleware(tool_configs={"add_numbers": True})],
)
agent = agent.compile(checkpointer=checkpointer)

# First invocation should be interrupted due to human-in-the-loop middleware
response = agent.invoke(
    {
        "messages": [
            HumanMessage(
                "Add 1 and 2, then return the weather forecast with temperature 72 and condition sunny."
            )
        ]
    },
    config={"configurable": {"thread_id": "1"}},
)
interrupt_description = response["__interrupt__"][0].value[0]["description"]
print(interrupt_description)
"""
Tool execution requires approval

Tool: add_numbers
Args: {'a': 1, 'b': 2}
"""

# Resume the agent with approval
response = agent.invoke(
    Command(resume=[{"type": "approve"}]), config={"configurable": {"thread_id": "1"}}
)

for msg in response["messages"]:
    msg.pretty_print()

"""
================================ Human Message =================================

Add 1 and 2, then return the weather forecast with temperature 72 and condition sunny.
================================== Ai Message ==================================
Tool Calls:
  WeatherBaseModel (call_u6nXsEYRJbqNx4AEHgiQMpE2)
 Call ID: call_u6nXsEYRJbqNx4AEHgiQMpE2
  Args:
    temperature: 72
    condition: sunny
  add_numbers (call_nuQEZF7PwfYDlVpnSt8eaInI)
 Call ID: call_nuQEZF7PwfYDlVpnSt8eaInI
  Args:
    a: 1
    b: 2
================================= Tool Message =================================
Name: WeatherBaseModel

Returning structured response: temperature=72.0 condition='sunny'
================================= Tool Message =================================
Name: add_numbers

3
"""

print(repr(response["response"]))
"""
WeatherBaseModel(temperature=72.0, condition='sunny')
"""

```
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think we should remove this. Folks wanting to use this old style can use what's in LangGraph. Should be deprecated.


message: str
args: dict
allowed_actions: list[Literal["approve", "reject", "edit"]]
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Using a more common pattern w/ access control where we use a simple list.

One point of confusion here is that we actually don't have an "edit" action below, it's rolled into approve.

I propose we maybe use approve_with_edits instead to make this more clear.

We can expand this in the future to have things like:

"approve_with_persistence"

I don't see a case where rejecting wouldn't expose the option for a message.

message_prefix: str = "Tool execution requires approval",
tool_configs: dict[str, bool | list[AllowedActions]],
*,
action_request_prefix: str = "Tool execution requires approval",
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think this is more clear


request: ActionRequest = {
"message": message,
"args": {"tool_name": tool_name, "tool_args": tool_args},
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we could just dump the tool call here and expect a tool call in return? wdyt @eyurtsev

class ApproveResponse(TypedDict):
"""Response when a human approves the action."""

action: Literal["approve"]
Copy link
Collaborator

Choose a reason for hiding this comment

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

name collision in action. action refers the action taken by the reviewer / HIL as well as to the action taken by the agent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I really like action here bc it matches w/ allowed_actions. Maybe we could find a better name for the request.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

But, if allowed_actions is going to bloat to have stuff like edits, persist, then my above point becomes less valuable. So maybe fine to use type here.

sydney-runkle added a commit that referenced this pull request Sep 17, 2025
# Main changes / new features

## Better support for parallel tool calls

1. Support for multiple tool calls requiring human input
2. Support for combination of tool calls requiring human input + those
that are auto-approved
3. Support structured output w/ tool calls requiring human input
4. Support structured output w/ standard tool calls

## Shortcut for allowed actions

Adds a shortcut where tool config can be specified as a `bool`, meaning
"all actions allowed"

```py
HumanInTheLoopMiddleware(tool_configs={"expensive_tool": True})
```

## A few design decisions here
* We only raise one interrupt w/ all `HumanInterrupt`s, currently we
won't be able to execute all tools until all of these are resolved. This
isn't super blocking bc we can't re-invoke the model until all tools
have finished execution. That being said, if you have a long running
auto-approved tool, this could slow things down.

## TODOs

* Ideally, we would rename `accept` -> `approve`
* Ideally, we would rename `respond` -> `reject`
* Docs update (@sydney-runkle to own)
* In another PR I'd like to refactor testing to have one file for each
prebuilt middleware :)

Fast follow to #32962
which was deemed as too breaking
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
langchain Related to the package `langchain`
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants