Skip to content

Commit 9bd401a

Browse files
fix: resumable shell, works w/ interrupts (#33978)
fixes #33684 Now able to run this minimal snippet successfully ```py import os from langchain.agents import create_agent from langchain.agents.middleware import ( HostExecutionPolicy, HumanInTheLoopMiddleware, ShellToolMiddleware, ) from langgraph.checkpoint.memory import InMemorySaver from langgraph.types import Command shell_middleware = ShellToolMiddleware( workspace_root=os.getcwd(), env=os.environ, # danger execution_policy=HostExecutionPolicy() ) hil_middleware = HumanInTheLoopMiddleware(interrupt_on={"shell": True}) checkpointer = InMemorySaver() agent = create_agent( "openai:gpt-4.1-mini", middleware=[shell_middleware, hil_middleware], checkpointer=checkpointer, ) input_message = {"role": "user", "content": "run `which python`"} config = {"configurable": {"thread_id": "1"}} result = agent.invoke( {"messages": [input_message]}, config=config, durability="exit", ) ```
1 parent 6aa3794 commit 9bd401a

File tree

3 files changed

+136
-38
lines changed

3 files changed

+136
-38
lines changed

libs/langchain_v1/langchain/agents/middleware/shell_tool.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import weakref
1616
from dataclasses import dataclass, field
1717
from pathlib import Path
18-
from typing import TYPE_CHECKING, Annotated, Any, Literal
18+
from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
1919

2020
from langchain_core.messages import ToolMessage
2121
from langchain_core.tools.base import ToolException
@@ -339,7 +339,7 @@ class _ShellToolInput(BaseModel):
339339
restart: bool | None = None
340340
"""Whether to restart the shell session."""
341341

342-
runtime: Annotated[Any, SkipJsonSchema] = None
342+
runtime: Annotated[Any, SkipJsonSchema()] = None
343343
"""The runtime for the shell tool.
344344
345345
Included as a workaround at the moment bc args_schema doesn't work with
@@ -445,7 +445,7 @@ def shell_tool(
445445
command: str | None = None,
446446
restart: bool = False,
447447
) -> ToolMessage | str:
448-
resources = self._ensure_resources(runtime.state)
448+
resources = self._get_or_create_resources(runtime.state)
449449
return self._run_shell_tool(
450450
resources,
451451
{"command": command, "restart": restart},
@@ -491,7 +491,7 @@ def _normalize_env(env: Mapping[str, Any] | None) -> dict[str, str] | None:
491491

492492
def before_agent(self, state: ShellToolState, runtime: Runtime) -> dict[str, Any] | None: # noqa: ARG002
493493
"""Start the shell session and run startup commands."""
494-
resources = self._create_resources()
494+
resources = self._get_or_create_resources(state)
495495
return {"shell_session_resources": resources}
496496

497497
async def abefore_agent(self, state: ShellToolState, runtime: Runtime) -> dict[str, Any] | None:
@@ -500,7 +500,10 @@ async def abefore_agent(self, state: ShellToolState, runtime: Runtime) -> dict[s
500500

501501
def after_agent(self, state: ShellToolState, runtime: Runtime) -> None: # noqa: ARG002
502502
"""Run shutdown commands and release resources when an agent completes."""
503-
resources = self._ensure_resources(state)
503+
resources = state.get("shell_session_resources")
504+
if not isinstance(resources, _SessionResources):
505+
# Resources were never created, nothing to clean up
506+
return
504507
try:
505508
self._run_shutdown_commands(resources.session)
506509
finally:
@@ -510,17 +513,26 @@ async def aafter_agent(self, state: ShellToolState, runtime: Runtime) -> None:
510513
"""Async run shutdown commands and release resources when an agent completes."""
511514
return self.after_agent(state, runtime)
512515

513-
def _ensure_resources(self, state: ShellToolState) -> _SessionResources:
516+
def _get_or_create_resources(self, state: ShellToolState) -> _SessionResources:
517+
"""Get existing resources from state or create new ones if they don't exist.
518+
519+
This method enables resumability by checking if resources already exist in the state
520+
(e.g., after an interrupt), and only creating new resources if they're not present.
521+
522+
Args:
523+
state: The agent state which may contain shell session resources.
524+
525+
Returns:
526+
Session resources, either retrieved from state or newly created.
527+
"""
514528
resources = state.get("shell_session_resources")
515-
if resources is not None and not isinstance(resources, _SessionResources):
516-
resources = None
517-
if resources is None:
518-
msg = (
519-
"Shell session resources are unavailable. Ensure `before_agent` ran successfully "
520-
"before invoking the shell tool."
521-
)
522-
raise ToolException(msg)
523-
return resources
529+
if isinstance(resources, _SessionResources):
530+
return resources
531+
532+
new_resources = self._create_resources()
533+
# Cast needed to make state dict-like for mutation
534+
cast("dict[str, Any]", state)["shell_session_resources"] = new_resources
535+
return new_resources
524536

525537
def _create_resources(self) -> _SessionResources:
526538
workspace = self._workspace_root

libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_shell_tool.py

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def test_executes_command_and_persists_state(tmp_path: Path) -> None:
3232
updates = middleware.before_agent(state, None)
3333
if updates:
3434
state.update(updates)
35-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
35+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
3636

3737
middleware._run_shell_tool(resources, {"command": "cd /"}, tool_call_id=None)
3838
result = middleware._run_shell_tool(resources, {"command": "pwd"}, tool_call_id=None)
@@ -55,14 +55,14 @@ def test_restart_resets_session_environment(tmp_path: Path) -> None:
5555
updates = middleware.before_agent(state, None)
5656
if updates:
5757
state.update(updates)
58-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
58+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
5959

6060
middleware._run_shell_tool(resources, {"command": "export FOO=bar"}, tool_call_id=None)
6161
restart_message = middleware._run_shell_tool(
6262
resources, {"restart": True}, tool_call_id=None
6363
)
6464
assert "restarted" in restart_message.lower()
65-
resources = middleware._ensure_resources(state) # reacquire after restart
65+
resources = middleware._get_or_create_resources(state) # reacquire after restart
6666
result = middleware._run_shell_tool(
6767
resources, {"command": "echo ${FOO:-unset}"}, tool_call_id=None
6868
)
@@ -81,7 +81,7 @@ def test_truncation_indicator_present(tmp_path: Path) -> None:
8181
updates = middleware.before_agent(state, None)
8282
if updates:
8383
state.update(updates)
84-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
84+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
8585
result = middleware._run_shell_tool(resources, {"command": "seq 1 20"}, tool_call_id=None)
8686
assert "Output truncated" in result
8787
finally:
@@ -98,7 +98,7 @@ def test_timeout_returns_error(tmp_path: Path) -> None:
9898
updates = middleware.before_agent(state, None)
9999
if updates:
100100
state.update(updates)
101-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
101+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
102102
start = time.monotonic()
103103
result = middleware._run_shell_tool(resources, {"command": "sleep 2"}, tool_call_id=None)
104104
elapsed = time.monotonic() - start
@@ -120,7 +120,7 @@ def test_redaction_policy_applies(tmp_path: Path) -> None:
120120
updates = middleware.before_agent(state, None)
121121
if updates:
122122
state.update(updates)
123-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
123+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
124124
message = middleware._run_shell_tool(
125125
resources,
126126
{"command": "printf 'Contact: [email protected]\\n'"},
@@ -222,7 +222,7 @@ def test_normalize_env_coercion(tmp_path: Path) -> None:
222222
updates = middleware.before_agent(state, None)
223223
if updates:
224224
state.update(updates)
225-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
225+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
226226
result = middleware._run_shell_tool(
227227
resources, {"command": "echo $NUM $BOOL"}, tool_call_id=None
228228
)
@@ -242,7 +242,7 @@ def test_shell_tool_missing_command_string(tmp_path: Path) -> None:
242242
updates = middleware.before_agent(state, None)
243243
if updates:
244244
state.update(updates)
245-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
245+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
246246

247247
with pytest.raises(ToolException, match="expects a 'command' string"):
248248
middleware._run_shell_tool(resources, {"command": None}, tool_call_id=None)
@@ -267,7 +267,7 @@ def test_tool_message_formatting_with_id(tmp_path: Path) -> None:
267267
updates = middleware.before_agent(state, None)
268268
if updates:
269269
state.update(updates)
270-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
270+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
271271

272272
result = middleware._run_shell_tool(
273273
resources, {"command": "echo test"}, tool_call_id="test-id-123"
@@ -292,7 +292,7 @@ def test_nonzero_exit_code_returns_error(tmp_path: Path) -> None:
292292
updates = middleware.before_agent(state, None)
293293
if updates:
294294
state.update(updates)
295-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
295+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
296296

297297
result = middleware._run_shell_tool(
298298
resources,
@@ -319,7 +319,7 @@ def test_truncation_by_bytes(tmp_path: Path) -> None:
319319
updates = middleware.before_agent(state, None)
320320
if updates:
321321
state.update(updates)
322-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
322+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
323323

324324
result = middleware._run_shell_tool(
325325
resources, {"command": "python3 -c 'print(\"x\" * 100)'"}, tool_call_id=None
@@ -379,15 +379,6 @@ def test_shutdown_command_timeout_logged(tmp_path: Path) -> None:
379379
middleware.after_agent(state, None)
380380

381381

382-
def test_ensure_resources_missing_state(tmp_path: Path) -> None:
383-
"""Test that _ensure_resources raises when resources are missing."""
384-
middleware = ShellToolMiddleware(workspace_root=tmp_path / "workspace")
385-
state: AgentState = _empty_state()
386-
387-
with pytest.raises(ToolException, match="Shell session resources are unavailable"):
388-
middleware._ensure_resources(state) # type: ignore[attr-defined]
389-
390-
391382
def test_empty_output_replaced_with_no_output(tmp_path: Path) -> None:
392383
"""Test that empty command output is replaced with '<no output>'."""
393384
middleware = ShellToolMiddleware(workspace_root=tmp_path / "workspace")
@@ -396,7 +387,7 @@ def test_empty_output_replaced_with_no_output(tmp_path: Path) -> None:
396387
updates = middleware.before_agent(state, None)
397388
if updates:
398389
state.update(updates)
399-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
390+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
400391

401392
result = middleware._run_shell_tool(
402393
resources,
@@ -419,7 +410,7 @@ def test_stderr_output_labeling(tmp_path: Path) -> None:
419410
updates = middleware.before_agent(state, None)
420411
if updates:
421412
state.update(updates)
422-
resources = middleware._ensure_resources(state) # type: ignore[attr-defined]
413+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
423414

424415
result = middleware._run_shell_tool(
425416
resources, {"command": "echo error >&2"}, tool_call_id=None
@@ -468,3 +459,98 @@ def test_async_methods_delegate_to_sync(tmp_path: Path) -> None:
468459
asyncio.run(middleware.aafter_agent(state, None))
469460
finally:
470461
pass
462+
463+
464+
def test_shell_middleware_resumable_after_interrupt(tmp_path: Path) -> None:
465+
"""Test that shell middleware is resumable after an interrupt.
466+
467+
This test simulates a scenario where:
468+
1. The middleware creates a shell session
469+
2. A command is executed
470+
3. The agent is interrupted (state is preserved)
471+
4. The agent resumes with the same state
472+
5. The shell session is reused (not recreated)
473+
"""
474+
workspace = tmp_path / "workspace"
475+
middleware = ShellToolMiddleware(workspace_root=workspace)
476+
477+
# Simulate first execution (before interrupt)
478+
state: AgentState = _empty_state()
479+
updates = middleware.before_agent(state, None)
480+
if updates:
481+
state.update(updates)
482+
483+
# Get the resources and verify they exist
484+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
485+
initial_session = resources.session
486+
initial_tempdir = resources.tempdir
487+
488+
# Execute a command to set state
489+
middleware._run_shell_tool(resources, {"command": "export TEST_VAR=hello"}, tool_call_id=None)
490+
491+
# Simulate interrupt - state is preserved, but we don't call after_agent
492+
# In a real scenario, the state would be checkpointed here
493+
494+
# Simulate resumption - call before_agent again with same state
495+
# This should reuse existing resources, not create new ones
496+
updates = middleware.before_agent(state, None)
497+
if updates:
498+
state.update(updates)
499+
500+
# Get resources again - should be the same session
501+
resumed_resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
502+
503+
# Verify the session was reused (same object reference)
504+
assert resumed_resources.session is initial_session
505+
assert resumed_resources.tempdir is initial_tempdir
506+
507+
# Verify the session state persisted (environment variable still set)
508+
result = middleware._run_shell_tool(
509+
resumed_resources, {"command": "echo ${TEST_VAR:-unset}"}, tool_call_id=None
510+
)
511+
assert "hello" in result
512+
assert "unset" not in result
513+
514+
# Clean up
515+
middleware.after_agent(state, None)
516+
517+
518+
def test_get_or_create_resources_creates_when_missing(tmp_path: Path) -> None:
519+
"""Test that _get_or_create_resources creates resources when they don't exist."""
520+
workspace = tmp_path / "workspace"
521+
middleware = ShellToolMiddleware(workspace_root=workspace)
522+
523+
state: AgentState = _empty_state()
524+
525+
# State has no resources initially
526+
assert "shell_session_resources" not in state
527+
528+
# Call _get_or_create_resources - should create new resources
529+
resources = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
530+
531+
assert isinstance(resources, _SessionResources)
532+
assert resources.session is not None
533+
assert state.get("shell_session_resources") is resources
534+
535+
# Clean up
536+
resources._finalizer()
537+
538+
539+
def test_get_or_create_resources_reuses_existing(tmp_path: Path) -> None:
540+
"""Test that _get_or_create_resources reuses existing resources."""
541+
workspace = tmp_path / "workspace"
542+
middleware = ShellToolMiddleware(workspace_root=workspace)
543+
544+
state: AgentState = _empty_state()
545+
546+
# Create resources first time
547+
resources1 = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
548+
549+
# Call again - should return the same resources
550+
resources2 = middleware._get_or_create_resources(state) # type: ignore[attr-defined]
551+
552+
assert resources1 is resources2
553+
assert resources1.session is resources2.session
554+
555+
# Clean up
556+
resources1._finalizer()

libs/langchain_v1/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)