langchain 1.0.0rc2__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {langchain-1.0.0rc2 → langchain-1.0.2}/PKG-INFO +12 -9
  2. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/__init__.py +1 -1
  3. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/factory.py +26 -20
  4. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/__init__.py +12 -0
  5. langchain-1.0.2/langchain/agents/middleware/_execution.py +388 -0
  6. langchain-1.0.2/langchain/agents/middleware/_redaction.py +350 -0
  7. langchain-1.0.2/langchain/agents/middleware/file_search.py +382 -0
  8. langchain-1.0.2/langchain/agents/middleware/pii.py +317 -0
  9. langchain-1.0.2/langchain/agents/middleware/shell_tool.py +718 -0
  10. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/types.py +7 -5
  11. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/chat_models/base.py +7 -17
  12. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/embeddings/__init__.py +6 -0
  13. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/embeddings/base.py +21 -7
  14. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/tools/tool_node.py +147 -61
  15. {langchain-1.0.0rc2 → langchain-1.0.2}/pyproject.toml +11 -10
  16. langchain-1.0.2/tests/integration_tests/agents/middleware/__init__.py +1 -0
  17. langchain-1.0.2/tests/integration_tests/agents/middleware/test_shell_tool_integration.py +146 -0
  18. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/__snapshots__/test_middleware_agent.ambr +0 -7
  19. langchain-1.0.2/tests/unit_tests/agents/__snapshots__/test_return_direct_graph.ambr +69 -0
  20. langchain-1.0.2/tests/unit_tests/agents/middleware/test_file_search.py +261 -0
  21. langchain-1.0.2/tests/unit_tests/agents/middleware/test_shell_execution_policies.py +404 -0
  22. langchain-1.0.2/tests/unit_tests/agents/middleware/test_shell_tool.py +175 -0
  23. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_middleware_agent.py +38 -26
  24. langchain-1.0.2/tests/unit_tests/agents/test_return_direct_graph.py +73 -0
  25. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_tool_node.py +186 -18
  26. langchain-1.0.2/tests/unit_tests/agents/test_tool_node_validation_error_filtering.py +678 -0
  27. {langchain-1.0.0rc2 → langchain-1.0.2}/uv.lock +141 -97
  28. langchain-1.0.0rc2/langchain/agents/middleware/pii.py +0 -751
  29. {langchain-1.0.0rc2 → langchain-1.0.2}/.gitignore +0 -0
  30. {langchain-1.0.0rc2 → langchain-1.0.2}/LICENSE +0 -0
  31. {langchain-1.0.0rc2 → langchain-1.0.2}/Makefile +0 -0
  32. {langchain-1.0.0rc2 → langchain-1.0.2}/README.md +0 -0
  33. {langchain-1.0.0rc2 → langchain-1.0.2}/extended_testing_deps.txt +0 -0
  34. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/__init__.py +0 -0
  35. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/context_editing.py +0 -0
  36. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/human_in_the_loop.py +0 -0
  37. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/model_call_limit.py +0 -0
  38. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/model_fallback.py +0 -0
  39. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/summarization.py +0 -0
  40. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/todo.py +0 -0
  41. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/tool_call_limit.py +0 -0
  42. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/tool_emulator.py +0 -0
  43. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/tool_retry.py +0 -0
  44. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/middleware/tool_selection.py +0 -0
  45. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/agents/structured_output.py +0 -0
  46. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/chat_models/__init__.py +0 -0
  47. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/messages/__init__.py +0 -0
  48. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/py.typed +0 -0
  49. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/rate_limiters/__init__.py +0 -0
  50. {langchain-1.0.0rc2 → langchain-1.0.2}/langchain/tools/__init__.py +0 -0
  51. {langchain-1.0.0rc2 → langchain-1.0.2}/scripts/check_imports.py +0 -0
  52. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/__init__.py +0 -0
  53. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/__init__.py +0 -0
  54. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/agents/__init__.py +0 -0
  55. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/agents/test_response_format.py +0 -0
  56. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/cache/__init__.py +0 -0
  57. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/cache/fake_embeddings.py +0 -0
  58. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/chat_models/__init__.py +0 -0
  59. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/chat_models/test_base.py +0 -0
  60. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/conftest.py +0 -0
  61. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/embeddings/__init__.py +0 -0
  62. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/embeddings/test_base.py +0 -0
  63. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/integration_tests/test_compile.py +0 -0
  64. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/__init__.py +0 -0
  65. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/__init__.py +0 -0
  66. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/__snapshots__/test_middleware_decorators.ambr +0 -0
  67. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/any_str.py +0 -0
  68. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/compose-postgres.yml +0 -0
  69. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/compose-redis.yml +0 -0
  70. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/conftest.py +0 -0
  71. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/conftest_checkpointer.py +0 -0
  72. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/conftest_store.py +0 -0
  73. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/memory_assert.py +0 -0
  74. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/messages.py +0 -0
  75. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/__init__.py +0 -0
  76. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_before_after_agent.py +0 -0
  77. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_llm_tool_selection.py +0 -0
  78. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_override_methods.py +0 -0
  79. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_tool_emulator.py +0 -0
  80. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_tool_retry.py +0 -0
  81. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_wrap_model_call_decorator.py +0 -0
  82. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_wrap_model_call_middleware.py +0 -0
  83. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/middleware/test_wrap_tool_call_decorator.py +0 -0
  84. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/model.py +0 -0
  85. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/specifications/responses.json +0 -0
  86. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/specifications/return_direct.json +0 -0
  87. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_context_editing_middleware.py +0 -0
  88. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_handler_composition.py +0 -0
  89. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_injected_runtime_create_agent.py +0 -0
  90. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_middleware_decorators.py +0 -0
  91. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_middleware_tools.py +0 -0
  92. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_model_fallback_middleware.py +0 -0
  93. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_on_tool_call_middleware.py +0 -0
  94. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_pii_middleware.py +0 -0
  95. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_react_agent.py +0 -0
  96. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_response_format.py +0 -0
  97. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_responses.py +0 -0
  98. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_responses_spec.py +0 -0
  99. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_return_direct_spec.py +0 -0
  100. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_state_schema.py +0 -0
  101. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_sync_async_tool_wrapper_composition.py +0 -0
  102. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_todo_middleware.py +0 -0
  103. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_tool_call_limit.py +0 -0
  104. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/test_tool_node_interceptor_unregistered.py +0 -0
  105. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/agents/utils.py +0 -0
  106. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/chat_models/__init__.py +0 -0
  107. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/chat_models/test_chat_models.py +0 -0
  108. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/conftest.py +0 -0
  109. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/embeddings/__init__.py +0 -0
  110. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/embeddings/test_base.py +0 -0
  111. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/embeddings/test_imports.py +0 -0
  112. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/stubs.py +0 -0
  113. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/test_dependencies.py +0 -0
  114. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/test_imports.py +0 -0
  115. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/test_pytest_config.py +0 -0
  116. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/tools/__init__.py +0 -0
  117. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/tools/test_imports.py +0 -0
  118. {langchain-1.0.0rc2 → langchain-1.0.2}/tests/unit_tests/tools/test_on_tool_call.py +0 -0
@@ -1,18 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain
3
- Version: 1.0.0rc2
3
+ Version: 1.0.2
4
4
  Summary: Building applications with LLMs through composability
5
- Project-URL: homepage, https://docs.langchain.com/
6
- Project-URL: repository, https://github.com/langchain-ai/langchain/tree/master/libs/langchain
7
- Project-URL: changelog, https://github.com/langchain-ai/langchain/releases?q=tag%3A%22langchain%3D%3D1%22
8
- Project-URL: twitter, https://x.com/LangChainAI
9
- Project-URL: slack, https://www.langchain.com/join-community
10
- Project-URL: reddit, https://www.reddit.com/r/LangChain/
5
+ Project-URL: Homepage, https://docs.langchain.com/
6
+ Project-URL: Documentation, https://reference.langchain.com/python/langchain/langchain/
7
+ Project-URL: Source, https://github.com/langchain-ai/langchain/tree/master/libs/langchain
8
+ Project-URL: Changelog, https://github.com/langchain-ai/langchain/releases?q=tag%3A%22langchain%3D%3D1%22
9
+ Project-URL: Twitter, https://x.com/LangChainAI
10
+ Project-URL: Slack, https://www.langchain.com/join-community
11
+ Project-URL: Reddit, https://www.reddit.com/r/LangChain/
11
12
  License: MIT
12
13
  License-File: LICENSE
13
14
  Requires-Python: <4.0.0,>=3.10.0
14
- Requires-Dist: langchain-core<2.0.0,>=1.0.0rc3
15
- Requires-Dist: langgraph<2.0.0,>=1.0.0a4
15
+ Requires-Dist: langchain-core<2.0.0,>=1.0.0
16
+ Requires-Dist: langgraph<1.1.0,>=1.0.0
16
17
  Requires-Dist: pydantic<3.0.0,>=2.7.4
17
18
  Provides-Extra: anthropic
18
19
  Requires-Dist: langchain-anthropic; extra == 'anthropic'
@@ -30,6 +31,8 @@ Provides-Extra: google-vertexai
30
31
  Requires-Dist: langchain-google-vertexai; extra == 'google-vertexai'
31
32
  Provides-Extra: groq
32
33
  Requires-Dist: langchain-groq; extra == 'groq'
34
+ Provides-Extra: huggingface
35
+ Requires-Dist: langchain-huggingface; extra == 'huggingface'
33
36
  Provides-Extra: mistralai
34
37
  Requires-Dist: langchain-mistralai; extra == 'mistralai'
35
38
  Provides-Extra: ollama
@@ -1,3 +1,3 @@
1
1
  """Main entrypoint into LangChain."""
2
2
 
3
- __version__ = "1.0.0rc2"
3
+ __version__ = "1.0.1"
@@ -3,15 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import itertools
6
- from typing import (
7
- TYPE_CHECKING,
8
- Annotated,
9
- Any,
10
- cast,
11
- get_args,
12
- get_origin,
13
- get_type_hints,
14
- )
6
+ from typing import TYPE_CHECKING, Annotated, Any, cast, get_args, get_origin, get_type_hints
15
7
 
16
8
  from langchain_core.language_models.chat_models import BaseChatModel
17
9
  from langchain_core.messages import AIMessage, AnyMessage, SystemMessage, ToolMessage
@@ -31,7 +23,8 @@ from langchain.agents.middleware.types import (
31
23
  ModelRequest,
32
24
  ModelResponse,
33
25
  OmitFromSchema,
34
- PublicAgentState,
26
+ _InputAgentState,
27
+ _OutputAgentState,
35
28
  )
36
29
  from langchain.agents.structured_output import (
37
30
  AutoStrategy,
@@ -527,12 +520,12 @@ def create_agent( # noqa: PLR0915
527
520
  name: str | None = None,
528
521
  cache: BaseCache | None = None,
529
522
  ) -> CompiledStateGraph[
530
- AgentState[ResponseT], ContextT, PublicAgentState[ResponseT], PublicAgentState[ResponseT]
523
+ AgentState[ResponseT], ContextT, _InputAgentState, _OutputAgentState[ResponseT]
531
524
  ]:
532
525
  """Creates an agent graph that calls tools in a loop until a stopping condition is met.
533
526
 
534
527
  For more details on using `create_agent`,
535
- visit [Agents](https://docs.langchain.com/oss/python/langchain/agents) documentation.
528
+ visit the [Agents](https://docs.langchain.com/oss/python/langchain/agents) docs.
536
529
 
537
530
  Args:
538
531
  model: The language model for the agent. Can be a string identifier
@@ -600,7 +593,7 @@ def create_agent( # noqa: PLR0915
600
593
 
601
594
 
602
595
  graph = create_agent(
603
- model="anthropic:claude-sonnet-4-5-20250929",
596
+ model="anthropic:claude-sonnet-4-5",
604
597
  tools=[check_weather],
605
598
  system_prompt="You are a helpful assistant",
606
599
  )
@@ -780,7 +773,7 @@ def create_agent( # noqa: PLR0915
780
773
 
781
774
  # create graph, add nodes
782
775
  graph: StateGraph[
783
- AgentState[ResponseT], ContextT, PublicAgentState[ResponseT], PublicAgentState[ResponseT]
776
+ AgentState[ResponseT], ContextT, _InputAgentState, _OutputAgentState[ResponseT]
784
777
  ] = StateGraph(
785
778
  state_schema=resolved_state_schema,
786
779
  input_schema=input_schema,
@@ -1225,6 +1218,15 @@ def create_agent( # noqa: PLR0915
1225
1218
  graph.add_edge(START, entry_node)
1226
1219
  # add conditional edges only if tools exist
1227
1220
  if tool_node is not None:
1221
+ # Only include exit_node in destinations if any tool has return_direct=True
1222
+ # or if there are structured output tools
1223
+ tools_to_model_destinations = [loop_entry_node]
1224
+ if (
1225
+ any(tool.return_direct for tool in tool_node.tools_by_name.values())
1226
+ or structured_output_tools
1227
+ ):
1228
+ tools_to_model_destinations.append(exit_node)
1229
+
1228
1230
  graph.add_conditional_edges(
1229
1231
  "tools",
1230
1232
  _make_tools_to_model_edge(
@@ -1233,7 +1235,7 @@ def create_agent( # noqa: PLR0915
1233
1235
  structured_output_tools=structured_output_tools,
1234
1236
  end_destination=exit_node,
1235
1237
  ),
1236
- [loop_entry_node, exit_node],
1238
+ tools_to_model_destinations,
1237
1239
  )
1238
1240
 
1239
1241
  # base destinations are tools and exit_node
@@ -1498,10 +1500,12 @@ def _make_tools_to_model_edge(
1498
1500
  last_ai_message, tool_messages = _fetch_last_ai_and_tool_messages(state["messages"])
1499
1501
 
1500
1502
  # 1. Exit condition: All executed tools have return_direct=True
1501
- if all(
1502
- tool_node.tools_by_name[c["name"]].return_direct
1503
- for c in last_ai_message.tool_calls
1504
- if c["name"] in tool_node.tools_by_name
1503
+ # Filter to only client-side tools (provider tools are not in tool_node)
1504
+ client_side_tool_calls = [
1505
+ c for c in last_ai_message.tool_calls if c["name"] in tool_node.tools_by_name
1506
+ ]
1507
+ if client_side_tool_calls and all(
1508
+ tool_node.tools_by_name[c["name"]].return_direct for c in client_side_tool_calls
1505
1509
  ):
1506
1510
  return end_destination
1507
1511
 
@@ -1518,7 +1522,9 @@ def _make_tools_to_model_edge(
1518
1522
 
1519
1523
 
1520
1524
  def _add_middleware_edge(
1521
- graph: StateGraph[AgentState, ContextT, PublicAgentState, PublicAgentState],
1525
+ graph: StateGraph[
1526
+ AgentState[ResponseT], ContextT, _InputAgentState, _OutputAgentState[ResponseT]
1527
+ ],
1522
1528
  *,
1523
1529
  name: str,
1524
1530
  default_destination: str,
@@ -17,6 +17,13 @@ from .human_in_the_loop import (
17
17
  from .model_call_limit import ModelCallLimitMiddleware
18
18
  from .model_fallback import ModelFallbackMiddleware
19
19
  from .pii import PIIDetectionError, PIIMiddleware
20
+ from .shell_tool import (
21
+ CodexSandboxExecutionPolicy,
22
+ DockerExecutionPolicy,
23
+ HostExecutionPolicy,
24
+ RedactionRule,
25
+ ShellToolMiddleware,
26
+ )
20
27
  from .summarization import SummarizationMiddleware
21
28
  from .todo import TodoListMiddleware
22
29
  from .tool_call_limit import ToolCallLimitMiddleware
@@ -42,7 +49,10 @@ __all__ = [
42
49
  "AgentMiddleware",
43
50
  "AgentState",
44
51
  "ClearToolUsesEdit",
52
+ "CodexSandboxExecutionPolicy",
45
53
  "ContextEditingMiddleware",
54
+ "DockerExecutionPolicy",
55
+ "HostExecutionPolicy",
46
56
  "HumanInTheLoopMiddleware",
47
57
  "InterruptOnConfig",
48
58
  "LLMToolEmulator",
@@ -53,6 +63,8 @@ __all__ = [
53
63
  "ModelResponse",
54
64
  "PIIDetectionError",
55
65
  "PIIMiddleware",
66
+ "RedactionRule",
67
+ "ShellToolMiddleware",
56
68
  "SummarizationMiddleware",
57
69
  "TodoListMiddleware",
58
70
  "ToolCallLimitMiddleware",
@@ -0,0 +1,388 @@
1
+ """Execution policies for the persistent shell middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import typing
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ try: # pragma: no cover - optional dependency on POSIX platforms
17
+ import resource
18
+ except ImportError: # pragma: no cover - non-POSIX systems
19
+ resource = None # type: ignore[assignment]
20
+
21
+
22
+ SHELL_TEMP_PREFIX = "langchain-shell-"
23
+
24
+
25
+ def _launch_subprocess(
26
+ command: Sequence[str],
27
+ *,
28
+ env: Mapping[str, str],
29
+ cwd: Path,
30
+ preexec_fn: typing.Callable[[], None] | None,
31
+ start_new_session: bool,
32
+ ) -> subprocess.Popen[str]:
33
+ return subprocess.Popen( # noqa: S603
34
+ list(command),
35
+ stdin=subprocess.PIPE,
36
+ stdout=subprocess.PIPE,
37
+ stderr=subprocess.PIPE,
38
+ cwd=cwd,
39
+ text=True,
40
+ encoding="utf-8",
41
+ errors="replace",
42
+ bufsize=1,
43
+ env=env,
44
+ preexec_fn=preexec_fn, # noqa: PLW1509
45
+ start_new_session=start_new_session,
46
+ )
47
+
48
+
49
+ if typing.TYPE_CHECKING:
50
+ from collections.abc import Mapping, Sequence
51
+ from pathlib import Path
52
+
53
+
54
+ @dataclass
55
+ class BaseExecutionPolicy(abc.ABC):
56
+ """Configuration contract for persistent shell sessions.
57
+
58
+ Concrete subclasses encapsulate how a shell process is launched and constrained.
59
+ Each policy documents its security guarantees and the operating environments in
60
+ which it is appropriate. Use :class:`HostExecutionPolicy` for trusted, same-host
61
+ execution; :class:`CodexSandboxExecutionPolicy` when the Codex CLI sandbox is
62
+ available and you want additional syscall restrictions; and
63
+ :class:`DockerExecutionPolicy` for container-level isolation using Docker.
64
+ """
65
+
66
+ command_timeout: float = 30.0
67
+ startup_timeout: float = 30.0
68
+ termination_timeout: float = 10.0
69
+ max_output_lines: int = 100
70
+ max_output_bytes: int | None = None
71
+
72
+ def __post_init__(self) -> None:
73
+ if self.max_output_lines <= 0:
74
+ msg = "max_output_lines must be positive."
75
+ raise ValueError(msg)
76
+
77
+ @abc.abstractmethod
78
+ def spawn(
79
+ self,
80
+ *,
81
+ workspace: Path,
82
+ env: Mapping[str, str],
83
+ command: Sequence[str],
84
+ ) -> subprocess.Popen[str]:
85
+ """Launch the persistent shell process."""
86
+
87
+
88
+ @dataclass
89
+ class HostExecutionPolicy(BaseExecutionPolicy):
90
+ """Run the shell directly on the host process.
91
+
92
+ This policy is best suited for trusted or single-tenant environments (CI jobs,
93
+ developer workstations, pre-sandboxed containers) where the agent must access the
94
+ host filesystem and tooling without additional isolation. It enforces optional CPU
95
+ and memory limits to prevent runaway commands but offers **no** filesystem or network
96
+ sandboxing; commands can modify anything the process user can reach.
97
+
98
+ On Linux platforms resource limits are applied with ``resource.prlimit`` after the
99
+ shell starts. On macOS, where ``prlimit`` is unavailable, limits are set in a
100
+ ``preexec_fn`` before ``exec``. In both cases the shell runs in its own process group
101
+ so timeouts can terminate the full subtree.
102
+ """
103
+
104
+ cpu_time_seconds: int | None = None
105
+ memory_bytes: int | None = None
106
+ create_process_group: bool = True
107
+
108
+ _limits_requested: bool = field(init=False, repr=False, default=False)
109
+
110
+ def __post_init__(self) -> None:
111
+ super().__post_init__()
112
+ if self.cpu_time_seconds is not None and self.cpu_time_seconds <= 0:
113
+ msg = "cpu_time_seconds must be positive if provided."
114
+ raise ValueError(msg)
115
+ if self.memory_bytes is not None and self.memory_bytes <= 0:
116
+ msg = "memory_bytes must be positive if provided."
117
+ raise ValueError(msg)
118
+ self._limits_requested = any(
119
+ value is not None for value in (self.cpu_time_seconds, self.memory_bytes)
120
+ )
121
+ if self._limits_requested and resource is None:
122
+ msg = (
123
+ "HostExecutionPolicy cpu/memory limits require the Python 'resource' module. "
124
+ "Either remove the limits or run on a POSIX platform."
125
+ )
126
+ raise RuntimeError(msg)
127
+
128
+ def spawn(
129
+ self,
130
+ *,
131
+ workspace: Path,
132
+ env: Mapping[str, str],
133
+ command: Sequence[str],
134
+ ) -> subprocess.Popen[str]:
135
+ process = _launch_subprocess(
136
+ list(command),
137
+ env=env,
138
+ cwd=workspace,
139
+ preexec_fn=self._create_preexec_fn(),
140
+ start_new_session=self.create_process_group,
141
+ )
142
+ self._apply_post_spawn_limits(process)
143
+ return process
144
+
145
+ def _create_preexec_fn(self) -> typing.Callable[[], None] | None:
146
+ if not self._limits_requested or self._can_use_prlimit():
147
+ return None
148
+
149
+ def _configure() -> None: # pragma: no cover - depends on OS
150
+ if self.cpu_time_seconds is not None:
151
+ limit = (self.cpu_time_seconds, self.cpu_time_seconds)
152
+ resource.setrlimit(resource.RLIMIT_CPU, limit)
153
+ if self.memory_bytes is not None:
154
+ limit = (self.memory_bytes, self.memory_bytes)
155
+ if hasattr(resource, "RLIMIT_AS"):
156
+ resource.setrlimit(resource.RLIMIT_AS, limit)
157
+ elif hasattr(resource, "RLIMIT_DATA"):
158
+ resource.setrlimit(resource.RLIMIT_DATA, limit)
159
+
160
+ return _configure
161
+
162
+ def _apply_post_spawn_limits(self, process: subprocess.Popen[str]) -> None:
163
+ if not self._limits_requested or not self._can_use_prlimit():
164
+ return
165
+ if resource is None: # pragma: no cover - defensive
166
+ return
167
+ pid = process.pid
168
+ if pid is None:
169
+ return
170
+ try:
171
+ prlimit = typing.cast("typing.Any", resource).prlimit
172
+ if self.cpu_time_seconds is not None:
173
+ prlimit(pid, resource.RLIMIT_CPU, (self.cpu_time_seconds, self.cpu_time_seconds))
174
+ if self.memory_bytes is not None:
175
+ limit = (self.memory_bytes, self.memory_bytes)
176
+ if hasattr(resource, "RLIMIT_AS"):
177
+ prlimit(pid, resource.RLIMIT_AS, limit)
178
+ elif hasattr(resource, "RLIMIT_DATA"):
179
+ prlimit(pid, resource.RLIMIT_DATA, limit)
180
+ except OSError as exc: # pragma: no cover - depends on platform support
181
+ msg = "Failed to apply resource limits via prlimit."
182
+ raise RuntimeError(msg) from exc
183
+
184
+ @staticmethod
185
+ def _can_use_prlimit() -> bool:
186
+ return (
187
+ resource is not None
188
+ and hasattr(resource, "prlimit")
189
+ and sys.platform.startswith("linux")
190
+ )
191
+
192
+
193
+ @dataclass
194
+ class CodexSandboxExecutionPolicy(BaseExecutionPolicy):
195
+ """Launch the shell through the Codex CLI sandbox.
196
+
197
+ Ideal when you have the Codex CLI installed and want the additional syscall and
198
+ filesystem restrictions provided by Anthropic's Seatbelt (macOS) or Landlock/seccomp
199
+ (Linux) profiles. Commands still run on the host, but within the sandbox requested by
200
+ the CLI. If the Codex binary is unavailable or the runtime lacks the required
201
+ kernel features (e.g., Landlock inside some containers), process startup fails with a
202
+ :class:`RuntimeError`.
203
+
204
+ Configure sandbox behaviour via ``config_overrides`` to align with your Codex CLI
205
+ profile. This policy does not add its own resource limits; combine it with
206
+ host-level guards (cgroups, container resource limits) as needed.
207
+ """
208
+
209
+ binary: str = "codex"
210
+ platform: typing.Literal["auto", "macos", "linux"] = "auto"
211
+ config_overrides: Mapping[str, typing.Any] = field(default_factory=dict)
212
+
213
+ def spawn(
214
+ self,
215
+ *,
216
+ workspace: Path,
217
+ env: Mapping[str, str],
218
+ command: Sequence[str],
219
+ ) -> subprocess.Popen[str]:
220
+ full_command = self._build_command(command)
221
+ return _launch_subprocess(
222
+ full_command,
223
+ env=env,
224
+ cwd=workspace,
225
+ preexec_fn=None,
226
+ start_new_session=False,
227
+ )
228
+
229
+ def _build_command(self, command: Sequence[str]) -> list[str]:
230
+ binary = self._resolve_binary()
231
+ platform_arg = self._determine_platform()
232
+ full_command: list[str] = [binary, "sandbox", platform_arg]
233
+ for key, value in sorted(dict(self.config_overrides).items()):
234
+ full_command.extend(["-c", f"{key}={self._format_override(value)}"])
235
+ full_command.append("--")
236
+ full_command.extend(command)
237
+ return full_command
238
+
239
+ def _resolve_binary(self) -> str:
240
+ path = shutil.which(self.binary)
241
+ if path is None:
242
+ msg = (
243
+ "Codex sandbox policy requires the '%s' CLI to be installed and available on PATH."
244
+ )
245
+ raise RuntimeError(msg % self.binary)
246
+ return path
247
+
248
+ def _determine_platform(self) -> str:
249
+ if self.platform != "auto":
250
+ return self.platform
251
+ if sys.platform.startswith("linux"):
252
+ return "linux"
253
+ if sys.platform == "darwin":
254
+ return "macos"
255
+ msg = (
256
+ "Codex sandbox policy could not determine a supported platform; "
257
+ "set 'platform' explicitly."
258
+ )
259
+ raise RuntimeError(msg)
260
+
261
+ @staticmethod
262
+ def _format_override(value: typing.Any) -> str:
263
+ try:
264
+ return json.dumps(value)
265
+ except TypeError:
266
+ return str(value)
267
+
268
+
269
+ @dataclass
270
+ class DockerExecutionPolicy(BaseExecutionPolicy):
271
+ """Run the shell inside a dedicated Docker container.
272
+
273
+ Choose this policy when commands originate from untrusted users or you require
274
+ strong isolation between sessions. By default the workspace is bind-mounted only when
275
+ it refers to an existing non-temporary directory; ephemeral sessions run without a
276
+ mount to minimise host exposure. The container's network namespace is disabled by
277
+ default (``--network none``) and you can enable further hardening via
278
+ ``read_only_rootfs`` and ``user``.
279
+
280
+ The security guarantees depend on your Docker daemon configuration. Run the agent on
281
+ a host where Docker is locked down (rootless mode, AppArmor/SELinux, etc.) and review
282
+ any additional volumes or capabilities passed through ``extra_run_args``. The default
283
+ image is ``python:3.12-alpine3.19``; supply a custom image if you need preinstalled
284
+ tooling.
285
+ """
286
+
287
+ binary: str = "docker"
288
+ image: str = "python:3.12-alpine3.19"
289
+ remove_container_on_exit: bool = True
290
+ network_enabled: bool = False
291
+ extra_run_args: Sequence[str] | None = None
292
+ memory_bytes: int | None = None
293
+ cpu_time_seconds: typing.Any | None = None
294
+ cpus: str | None = None
295
+ read_only_rootfs: bool = False
296
+ user: str | None = None
297
+
298
+ def __post_init__(self) -> None:
299
+ super().__post_init__()
300
+ if self.memory_bytes is not None and self.memory_bytes <= 0:
301
+ msg = "memory_bytes must be positive if provided."
302
+ raise ValueError(msg)
303
+ if self.cpu_time_seconds is not None:
304
+ msg = (
305
+ "DockerExecutionPolicy does not support cpu_time_seconds; configure CPU limits "
306
+ "using Docker run options such as '--cpus'."
307
+ )
308
+ raise RuntimeError(msg)
309
+ if self.cpus is not None and not self.cpus.strip():
310
+ msg = "cpus must be a non-empty string when provided."
311
+ raise ValueError(msg)
312
+ if self.user is not None and not self.user.strip():
313
+ msg = "user must be a non-empty string when provided."
314
+ raise ValueError(msg)
315
+ self.extra_run_args = tuple(self.extra_run_args or ())
316
+
317
+ def spawn(
318
+ self,
319
+ *,
320
+ workspace: Path,
321
+ env: Mapping[str, str],
322
+ command: Sequence[str],
323
+ ) -> subprocess.Popen[str]:
324
+ full_command = self._build_command(workspace, env, command)
325
+ host_env = os.environ.copy()
326
+ return _launch_subprocess(
327
+ full_command,
328
+ env=host_env,
329
+ cwd=workspace,
330
+ preexec_fn=None,
331
+ start_new_session=False,
332
+ )
333
+
334
+ def _build_command(
335
+ self,
336
+ workspace: Path,
337
+ env: Mapping[str, str],
338
+ command: Sequence[str],
339
+ ) -> list[str]:
340
+ binary = self._resolve_binary()
341
+ full_command: list[str] = [binary, "run", "-i"]
342
+ if self.remove_container_on_exit:
343
+ full_command.append("--rm")
344
+ if not self.network_enabled:
345
+ full_command.extend(["--network", "none"])
346
+ if self.memory_bytes is not None:
347
+ full_command.extend(["--memory", str(self.memory_bytes)])
348
+ if self._should_mount_workspace(workspace):
349
+ host_path = str(workspace)
350
+ full_command.extend(["-v", f"{host_path}:{host_path}"])
351
+ full_command.extend(["-w", host_path])
352
+ else:
353
+ full_command.extend(["-w", "/"])
354
+ if self.read_only_rootfs:
355
+ full_command.append("--read-only")
356
+ for key, value in env.items():
357
+ full_command.extend(["-e", f"{key}={value}"])
358
+ if self.cpus is not None:
359
+ full_command.extend(["--cpus", self.cpus])
360
+ if self.user is not None:
361
+ full_command.extend(["--user", self.user])
362
+ if self.extra_run_args:
363
+ full_command.extend(self.extra_run_args)
364
+ full_command.append(self.image)
365
+ full_command.extend(command)
366
+ return full_command
367
+
368
+ @staticmethod
369
+ def _should_mount_workspace(workspace: Path) -> bool:
370
+ return not workspace.name.startswith(SHELL_TEMP_PREFIX)
371
+
372
+ def _resolve_binary(self) -> str:
373
+ path = shutil.which(self.binary)
374
+ if path is None:
375
+ msg = (
376
+ "Docker execution policy requires the '%s' CLI to be installed"
377
+ " and available on PATH."
378
+ )
379
+ raise RuntimeError(msg % self.binary)
380
+ return path
381
+
382
+
383
+ __all__ = [
384
+ "BaseExecutionPolicy",
385
+ "CodexSandboxExecutionPolicy",
386
+ "DockerExecutionPolicy",
387
+ "HostExecutionPolicy",
388
+ ]