Repository: langchain-ai/deepagents (python)

Issue:
Support SystemMessage type for create_deep_agent(system_prompt=...) (align with langchain.agents.create_agent)

#### Summary

`langchain.agents.create_agent` now supports:

```python
system_prompt: str | SystemMessage | None = None
```

However, `deepagents.create_deep_agent` still exposes:

```python
system_prompt: str | None = None
```

This means DeepAgents cannot directly consume a `SystemMessage` (or a pipeline that already models system prompts as `SystemMessage`), even though the underlying `create_agent` does support it.

It would be very helpful if `create_deep_agent` could accept both `str` **and** `SystemMessage`, and internally adapt to the existing DeepAgents prompt composition logic.

---

#### Motivation

* In many codebases, system prompts are already modeled as **structured objects** (`SystemMessage` or custom prompt objects) rather than plain strings.
* `create_agent` now allows passing `SystemMessage` directly, which makes it easy to reuse these higher-level models when building agents.
* DeepAgents is positioned as a higher-level **Agent Harness** on top of LangGraph/LangChain. It would be natural if it did not “force” callers to downcast back to `str` while the lower-level `create_agent` already supports `SystemMessage`.

Concretely, in our project:

* We maintain our own `SystemPrompt` / `SystemMessage` abstraction (with sections like *role*, *constraints*, *style*, etc.).
* For `create_agent`, we can now pass a `SystemMessage` directly.
* For `create_deep_agent`, we currently need a separate `to_legacy_system_prompt_str()` adapter solely to satisfy the `str` type, even though downstream everything ends up as messages anyway.

This makes DeepAgents feel “less typed” than the underlying `create_agent`, and it complicates sharing a common prompt model between DeepAgents and non-DeepAgents agents.

---

#### Current behavior

```python
from deepagents import create_deep_agent
from langchain_core.messages import SystemMessage

sys_msg = SystemMessage(content="You are a deep research assistant...")

# ❌ Type mismatch: create_deep_agent only accepts `str | None`
agent = create_deep_agent(
    model="openai:gpt-4o",
    tools=[...],
    system_prompt=sys_msg,   # not allowed by the type signature
)
```

We instead have to do:

```python
agent = create_deep_agent(
    model="openai:gpt-4o",
    tools=[...],
    system_prompt=sys_msg.content,  # manual flattening
)
```

or:

```python
system_prompt = my_system_prompt.to_legacy_system_prompt_str()

agent = create_deep_agent(
    model="openai:gpt-4o",
    tools=[...],
    system_prompt=system_prompt,
)
```

---

#### Desired behavior

Ideally, `create_deep_agent` would have a signature like:

```python
from langchain_core.messages import SystemMessage

def create_deep_agent(
    model: str | BaseChatModel | None = None,
    tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None,
    *,
    system_prompt: str | SystemMessage | None = None,
    ...
) -> CompiledStateGraph[...]:
    ...
```

Semantics:

* If `system_prompt` is a `str`, keep the current behavior (prepend to the base DeepAgents system prompt).
* If `system_prompt` is a `SystemMessage`, internally convert it to a string (or to a single system message block) following the existing DeepAgents composition logic.
* If `None`, keep the current default behavior (use the built-in base prompt).

This keeps full backwards compatibility while allowing higher-level code to pass `SystemMessage` directly, just like `create_agent` does.

---

#### Alternatives considered

* **Keep a separate `to_legacy_system_prompt_str()` adapter on the user side**

  This works today, but:

  * It duplicates logic that `create_agent` already encapsulates for `SystemMessage`.
  * It forces DeepAgents users to “downgrade” to `str` even if their internal representation is already `SystemMessage`.

* **Bypass `create_deep_agent` and manually compose middlewares + `create_agent`**

  Technically possible, but it defeats the purpose of DeepAgents as a higher-level harness:

  * We would have to reimplement parts of DeepAgents behavior (planning, filesystem, subagents, self-management) just to support `SystemMessage` at the top level.
  * This increases complexity and fragmentation across projects.

---

#### Backwards compatibility

* The change is **fully backward compatible** if implemented as `str | SystemMessage | None`:

  * Existing code passing `str` continues to work.
  * New code can opt in to pass `SystemMessage`.
* Internally, DeepAgents can still normalize everything to a single system prompt string if that’s what the current infrastructure expects.

---

#### Additional context

* `langchain.agents.create_agent` already supports `SystemMessage` for `system_prompt`, so aligning DeepAgents with this behavior would make the ecosystem **more consistent** and easier to reason about.
* Many of the emerging patterns around **provider-specific system prompts** (e.g. Anthropic multi-block system prompts, cache control, etc.) are modeled in user code as `SystemMessage` or higher-level prompt objects. Being able to pass these directly into DeepAgents would reduce friction and boilerplate.

Thanks for considering this! Happy to adjust the proposal if there’s a better place in the API to expose `SystemMessage` support (e.g. a separate `system_message` parameter, a builder pattern, etc.).



Patch:
diff --git a/libs/deepagents/deepagents/graph.py b/libs/deepagents/deepagents/graph.py
index 89ce8e0008..40e09705ac 100644
--- a/libs/deepagents/deepagents/graph.py
+++ b/libs/deepagents/deepagents/graph.py
@@ -12,6 +12,7 @@
 from langchain_anthropic import ChatAnthropic
 from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
 from langchain_core.language_models import BaseChatModel
+from langchain_core.messages import SystemMessage
 from langchain_core.tools import BaseTool
 from langgraph.cache.base import BaseCache
 from langgraph.graph.state import CompiledStateGraph
@@ -45,7 +46,7 @@ def create_deep_agent(
     model: str | BaseChatModel | None = None,
     tools: Sequence[BaseTool | Callable | dict[str, Any]] | None = None,
     *,
-    system_prompt: str | None = None,
+    system_prompt: str | SystemMessage | None = None,
     middleware: Sequence[AgentMiddleware] = (),
     subagents: list[SubAgent | CompiledSubAgent] | None = None,
     skills: list[str] | None = None,
@@ -83,7 +84,7 @@ def create_deep_agent(
             file management, and subagent spawning.
         system_prompt: The additional instructions the agent should have.
 
-            Will go in the system prompt.
+            Will go in the system prompt. Can be a string or a `SystemMessage`.
         middleware: Additional middleware to apply after standard middleware.
         subagents: The subagents to use.
 
@@ -202,9 +203,23 @@ def create_deep_agent(
     if interrupt_on is not None:
         deepagent_middleware.append(HumanInTheLoopMiddleware(interrupt_on=interrupt_on))
 
+    # Combine system_prompt with BASE_AGENT_PROMPT
+    if system_prompt is None:
+        final_system_prompt: str | SystemMessage = BASE_AGENT_PROMPT
+    elif isinstance(system_prompt, SystemMessage):
+        # SystemMessage: append BASE_AGENT_PROMPT to content_blocks
+        new_content = [
+            *system_prompt.content_blocks,
+            {"type": "text", "text": f"\n\n{BASE_AGENT_PROMPT}"},
+        ]
+        final_system_prompt = SystemMessage(content=new_content)
+    else:
+        # String: simple concatenation
+        final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT
+
     return create_agent(
         model,
-        system_prompt=system_prompt + "\n\n" + BASE_AGENT_PROMPT if system_prompt else BASE_AGENT_PROMPT,
+        system_prompt=final_system_prompt,
         tools=tools,
         middleware=deepagent_middleware,
         response_format=response_format,
diff --git a/libs/deepagents/deepagents/middleware/_utils.py b/libs/deepagents/deepagents/middleware/_utils.py
new file mode 100644
index 0000000000..6c2fa51d8a
--- /dev/null
+++ b/libs/deepagents/deepagents/middleware/_utils.py
@@ -0,0 +1,23 @@
+"""Utility functions for middleware."""
+
+from langchain_core.messages import SystemMessage
+
+
+def append_to_system_message(
+    system_message: SystemMessage | None,
+    text: str,
+) -> SystemMessage:
+    """Append text to a system message.
+
+    Args:
+        system_message: Existing system message or None.
+        text: Text to add to the system message.
+
+    Returns:
+        New SystemMessage with the text appended.
+    """
+    new_content: list[str | dict[str, str]] = list(system_message.content_blocks) if system_message else []
+    if new_content:
+        text = f"\n\n{text}"
+    new_content.append({"type": "text", "text": text})
+    return SystemMessage(content=new_content)
diff --git a/libs/deepagents/deepagents/middleware/filesystem.py b/libs/deepagents/deepagents/middleware/filesystem.py
index 1419f51c0a..159df8fd4a 100644
--- a/libs/deepagents/deepagents/middleware/filesystem.py
+++ b/libs/deepagents/deepagents/middleware/filesystem.py
@@ -33,6 +33,7 @@
     sanitize_tool_call_id,
     truncate_if_too_long,
 )
+from deepagents.middleware._utils import append_to_system_message
 
 EMPTY_CONTENT_WARNING = "System reminder: File exists but has empty contents"
 MAX_LINE_LENGTH = 2000
@@ -981,7 +982,8 @@ def wrap_model_call(
             system_prompt = "\n\n".join(prompt_parts)
 
         if system_prompt:
-            request = request.override(system_prompt=request.system_prompt + "\n\n" + system_prompt if request.system_prompt else system_prompt)
+            new_system_message = append_to_system_message(request.system_message, system_prompt)
+            request = request.override(system_message=new_system_message)
 
         return handler(request)
 
@@ -1028,7 +1030,8 @@ async def awrap_model_call(
             system_prompt = "\n\n".join(prompt_parts)
 
         if system_prompt:
-            request = request.override(system_prompt=request.system_prompt + "\n\n" + system_prompt if request.system_prompt else system_prompt)
+            new_system_message = append_to_system_message(request.system_message, system_prompt)
+            request = request.override(system_message=new_system_message)
 
         return await handler(request)
 
diff --git a/libs/deepagents/deepagents/middleware/memory.py b/libs/deepagents/deepagents/middleware/memory.py
index d6b03c1fd9..e43b7e923c 100644
--- a/libs/deepagents/deepagents/middleware/memory.py
+++ b/libs/deepagents/deepagents/middleware/memory.py
@@ -53,7 +53,6 @@
 from collections.abc import Awaitable, Callable
 from typing import TYPE_CHECKING, Annotated, NotRequired, TypedDict
 
-from langchain.messages import SystemMessage
 from langchain_core.runnables import RunnableConfig
 
 if TYPE_CHECKING:
@@ -69,6 +68,8 @@
 from langchain.tools import ToolRuntime
 from langgraph.runtime import Runtime
 
+from deepagents.middleware._utils import append_to_system_message
+
 logger = logging.getLogger(__name__)
 
 
@@ -354,23 +355,20 @@ async def abefore_agent(self, state: MemoryState, runtime: Runtime, config: Runn
         return MemoryStateUpdate(memory_contents=contents)
 
     def modify_request(self, request: ModelRequest) -> ModelRequest:
-        """Inject memory content into the system prompt.
+        """Inject memory content into the system message.
 
         Args:
             request: Model request to modify.
 
         Returns:
-            Modified request with memory injected into system prompt.
+            Modified request with memory injected into system message.
         """
         contents = request.state.get("memory_contents", {})
         agent_memory = self._format_agent_memory(contents)
 
-        if request.system_prompt:
-            system_prompt = agent_memory + "\n\n" + request.system_prompt
-        else:
-            system_prompt = agent_memory
+        new_system_message = append_to_system_message(request.system_message, agent_memory)
 
-        return request.override(system_message=SystemMessage(system_prompt))
+        return request.override(system_message=new_system_message)
 
     def wrap_model_call(
         self,
diff --git a/libs/deepagents/deepagents/middleware/skills.py b/libs/deepagents/deepagents/middleware/skills.py
index b3592b5174..212f6454d5 100644
--- a/libs/deepagents/deepagents/middleware/skills.py
+++ b/libs/deepagents/deepagents/middleware/skills.py
@@ -114,6 +114,8 @@
 from langgraph.prebuilt import ToolRuntime
 from langgraph.runtime import Runtime
 
+from deepagents.middleware._utils import append_to_system_message
+
 logger = logging.getLogger(__name__)
 
 # Security: Maximum size for SKILL.md files to prevent DoS attacks (10MB)
@@ -563,13 +565,13 @@ def _format_skills_list(self, skills: list[SkillMetadata]) -> str:
         return "\n".join(lines)
 
     def modify_request(self, request: ModelRequest) -> ModelRequest:
-        """Inject skills documentation into a model request's system prompt.
+        """Inject skills documentation into a model request's system message.
 
         Args:
             request: Model request to modify
 
         Returns:
-            New model request with skills documentation injected into system prompt
+            New model request with skills documentation injected into system message
         """
         skills_metadata = request.state.get("skills_metadata", [])
         skills_locations = self._format_skills_locations()
@@ -580,12 +582,9 @@ def modify_request(self, request: ModelRequest) -> ModelRequest:
             skills_list=skills_list,
         )
 
-        if request.system_prompt:
-            system_prompt = request.system_prompt + "\n\n" + skills_section
-        else:
-            system_prompt = skills_section
+        new_system_message = append_to_system_message(request.system_message, skills_section)
 
-        return request.override(system_prompt=system_prompt)
+        return request.override(system_message=new_system_message)
 
     def before_agent(self, state: SkillsState, runtime: Runtime, config: RunnableConfig) -> SkillsStateUpdate | None:
         """Load skills metadata before agent execution (synchronous).
diff --git a/libs/deepagents/deepagents/middleware/subagents.py b/libs/deepagents/deepagents/middleware/subagents.py
index 3aa3896ccb..fb0b12e859 100644
--- a/libs/deepagents/deepagents/middleware/subagents.py
+++ b/libs/deepagents/deepagents/middleware/subagents.py
@@ -13,6 +13,8 @@
 from langchain_core.tools import StructuredTool
 from langgraph.types import Command
 
+from deepagents.middleware._utils import append_to_system_message
+
 
 class SubAgent(TypedDict):
     """Specification for an agent.
@@ -522,10 +524,10 @@ def wrap_model_call(
         request: ModelRequest,
         handler: Callable[[ModelRequest], ModelResponse],
     ) -> ModelResponse:
-        """Update the system prompt to include instructions on using subagents."""
+        """Update the system message to include instructions on using subagents."""
         if self.system_prompt is not None:
-            system_prompt = request.system_prompt + "\n\n" + self.system_prompt if request.system_prompt else self.system_prompt
-            return handler(request.override(system_prompt=system_prompt))
+            new_system_message = append_to_system_message(request.system_message, self.system_prompt)
+            return handler(request.override(system_message=new_system_message))
         return handler(request)
 
     async def awrap_model_call(
@@ -533,8 +535,8 @@ async def awrap_model_call(
         request: ModelRequest,
         handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
     ) -> ModelResponse:
-        """(async) Update the system prompt to include instructions on using subagents."""
+        """(async) Update the system message to include instructions on using subagents."""
         if self.system_prompt is not None:
-            system_prompt = request.system_prompt + "\n\n" + self.system_prompt if request.system_prompt else self.system_prompt
-            return await handler(request.override(system_prompt=system_prompt))
+            new_system_message = append_to_system_message(request.system_message, self.system_prompt)
+            return await handler(request.override(system_message=new_system_message))
         return await handler(request)


Test patch:
diff --git a/libs/deepagents/tests/unit_tests/test_end_to_end.py b/libs/deepagents/tests/unit_tests/test_end_to_end.py
index ebc4d43aec..1d1059e06c 100644
--- a/libs/deepagents/tests/unit_tests/test_end_to_end.py
+++ b/libs/deepagents/tests/unit_tests/test_end_to_end.py
@@ -1,15 +1,17 @@
 """End-to-end unit tests for deepagents with fake LLM models."""
 
-from collections.abc import Callable, Sequence
+from collections.abc import Awaitable, Callable, Sequence
 from pathlib import Path
 from typing import Any
 from unittest.mock import patch
 
 import pytest
+from langchain.agents.middleware import AgentMiddleware
+from langchain.agents.middleware.types import ModelRequest, ModelResponse
 from langchain.tools import ToolRuntime
 from langchain_core.language_models import LanguageModelInput
 from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
-from langchain_core.messages import AIMessage, HumanMessage
+from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
 from langchain_core.runnables import Runnable
 from langchain_core.tools import BaseTool, tool
 from langgraph.store.memory import InMemoryStore
@@ -20,6 +22,32 @@
 from deepagents.backends.store import StoreBackend
 from deepagents.graph import create_deep_agent
 from deepagents.middleware.filesystem import MAX_LINE_LENGTH
+from tests.utils import assert_all_deepagent_qualities
+
+
+class SystemMessageCapturingMiddleware(AgentMiddleware):
+    """Middleware that captures the system message for testing purposes."""
+
+    def __init__(self) -> None:
+        self.captured_system_messages: list = []
+
+    def wrap_model_call(
+        self,
+        request: ModelRequest,
+        handler: Callable[[ModelRequest], ModelResponse],
+    ) -> ModelResponse:
+        if request.system_message is not None:
+            self.captured_system_messages.append(request.system_message)
+        return handler(request)
+
+    async def awrap_model_call(
+        self,
+        request: ModelRequest,
+        handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
+    ) -> ModelResponse:
+        if request.system_message is not None:
+            self.captured_system_messages.append(request.system_message)
+        return await handler(request)
 
 
 @tool(description="Sample tool")
@@ -496,3 +524,49 @@ def test_deep_agent_truncate_lines_empty_file(self, tmp_path: Path, backend_fact
         # Empty file should return empty or minimal content
         # (Backend might add warnings or format)
         assert isinstance(file_content, str)
+
+    def test_deep_agent_with_system_message(self) -> None:
+        """Test that create_deep_agent accepts a SystemMessage for system_prompt."""
+        model = FixedGenericFakeChatModel(
+            messages=iter(
+                [
+                    AIMessage(content="Hello! How can I help you today?"),
+                ]
+            )
+        )
+        capturing_middleware = SystemMessageCapturingMiddleware()
+        system_msg = SystemMessage(
+            content=[
+                {"type": "text", "text": "You are a helpful assistant."},
+                {"type": "text", "text": "Always be polite."},
+            ]
+        )
+        agent = create_deep_agent(model=model, system_prompt=system_msg, middleware=[capturing_middleware])
+        assert_all_deepagent_qualities(agent)
+
+        agent.invoke({"messages": [HumanMessage(content="Hello")]})
+
+        content = str(capturing_middleware.captured_system_messages[0].content)
+        assert "You are a helpful assistant." in content
+        assert "Always be polite." in content
+        assert "you have access to a number of standard tools" in content
+
+    def test_deep_agent_with_system_message_string_content(self) -> None:
+        """Test that create_deep_agent accepts a SystemMessage with string content."""
+        model = FixedGenericFakeChatModel(
+            messages=iter(
+                [
+                    AIMessage(content="Hello! I'm your research assistant."),
+                ]
+            )
+        )
+        capturing_middleware = SystemMessageCapturingMiddleware()
+        system_msg = SystemMessage(content="You are a helpful research assistant.")
+        agent = create_deep_agent(model=model, system_prompt=system_msg, middleware=[capturing_middleware])
+        assert_all_deepagent_qualities(agent)
+
+        agent.invoke({"messages": [HumanMessage(content="Hello")]})
+
+        content = str(capturing_middleware.captured_system_messages[0].content)
+        assert "You are a helpful research assistant." in content
+        assert "you have access to a number of standard tools" in content