Skip to content

Commit e212d45

Browse files
feat(sdk): accept inline system_prompt kwarg on Agent (#2826)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 74112a9 commit e212d45

File tree

3 files changed

+141
-2
lines changed

3 files changed

+141
-2
lines changed

openhands-sdk/openhands/sdk/agent/agent.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,16 @@ class Agent(CriticMixin, AgentBase):
243243
Attributes:
244244
llm: The language model instance used for reasoning.
245245
tools: List of tools available to the agent.
246-
name: Optional agent identifier.
247-
system_prompt: Custom system prompt (uses default if not provided).
246+
system_prompt: Inline system prompt string. When provided the agent
247+
uses this text verbatim instead of rendering from a template.
248+
Mutually exclusive with a non-default ``system_prompt_filename``.
249+
**Not recommended** unless you know what you are doing (e.g.
250+
customising agent behaviour for a completely different task) —
251+
this will override OpenHands' built-in system instructions.
252+
system_prompt_filename: Jinja2 template filename resolved relative to
253+
the agent's prompts directory, or an absolute path. Defaults to
254+
``"system_prompt.j2"``.
255+
system_prompt_kwargs: Extra kwargs forwarded to the Jinja2 template.
248256
249257
Example:
250258
```python
@@ -255,6 +263,14 @@ class Agent(CriticMixin, AgentBase):
255263
tools = [Tool(name="TerminalTool"), Tool(name="FileEditorTool")]
256264
agent = Agent(llm=llm, tools=tools)
257265
```
266+
267+
To override the system prompt entirely::
268+
269+
agent = Agent(
270+
llm=llm,
271+
tools=tools,
272+
system_prompt="You are a helpful coding assistant.",
273+
)
258274
"""
259275

260276
_parallel_executor: ParallelToolExecutor = PrivateAttr(

openhands-sdk/openhands/sdk/agent/base.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ConfigDict,
1414
Field,
1515
PrivateAttr,
16+
model_validator,
1617
)
1718

1819
from openhands.sdk.context.agent_context import AgentContext
@@ -135,6 +136,19 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
135136
}
136137
],
137138
)
139+
system_prompt: str | None = Field(
140+
default=None,
141+
description=(
142+
"Inline system prompt string. When provided, the agent uses this "
143+
"text verbatim as the system message instead of rendering from "
144+
"`system_prompt_filename`. Mutually exclusive with a non-default "
145+
"`system_prompt_filename`.\n\n"
146+
"**Warning**: This is not recommended unless you know what you are "
147+
"doing (e.g. customising agent behaviour for a completely different "
148+
"task). Setting this will override OpenHands' built-in system "
149+
"instructions that govern default agent behaviour."
150+
),
151+
)
138152
system_prompt_filename: str = Field(
139153
default="system_prompt.j2",
140154
description=(
@@ -159,6 +173,23 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
159173
examples=[{"cli_mode": True}],
160174
)
161175

176+
@model_validator(mode="before")
177+
@classmethod
178+
def _validate_system_prompt_fields(cls, data: Any) -> Any:
179+
if not isinstance(data, dict):
180+
return data
181+
has_inline = data.get("system_prompt") is not None
182+
has_custom_filename = (
183+
"system_prompt_filename" in data
184+
and data["system_prompt_filename"] != "system_prompt.j2"
185+
)
186+
if has_inline and has_custom_filename:
187+
raise ValueError(
188+
"Cannot set both 'system_prompt' and a non-default "
189+
"'system_prompt_filename'. Use one or the other."
190+
)
191+
return data
192+
162193
condenser: CondenserBase | None = Field(
163194
default=None,
164195
description="Optional condenser to use for condensing conversation history.",
@@ -223,9 +254,15 @@ def static_system_message(self) -> str:
223254
per-conversation context. This static portion can be cached and reused
224255
across conversations for better prompt caching efficiency.
225256
257+
When ``system_prompt`` is set, that string is returned verbatim,
258+
bypassing Jinja2 template rendering entirely.
259+
226260
Returns:
227261
The rendered system prompt template without dynamic context.
228262
"""
263+
if self.system_prompt is not None:
264+
return self.system_prompt
265+
229266
template_kwargs = dict(self.system_prompt_kwargs)
230267
# Auto-detect browser tools from the tool spec list
231268
template_kwargs.setdefault(
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Tests for the system_prompt inline override on Agent / AgentBase."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from openhands.sdk.agent import Agent
8+
from openhands.sdk.agent.base import AgentBase
9+
from openhands.sdk.llm import LLM
10+
11+
12+
def _make_llm() -> LLM:
13+
return LLM(model="test-model", usage_id="test")
14+
15+
16+
# --- construction ---
17+
18+
19+
def test_system_prompt_is_accepted_and_stored() -> None:
20+
agent = Agent(llm=_make_llm(), tools=[], system_prompt="CUSTOM")
21+
assert agent.system_prompt == "CUSTOM"
22+
23+
24+
def test_system_prompt_defaults_to_none() -> None:
25+
agent = Agent(llm=_make_llm(), tools=[])
26+
assert agent.system_prompt is None
27+
28+
29+
# --- static_system_message uses inline prompt ---
30+
31+
32+
def test_static_system_message_returns_inline_prompt() -> None:
33+
agent = Agent(llm=_make_llm(), tools=[], system_prompt="MY PROMPT")
34+
assert agent.static_system_message == "MY PROMPT"
35+
36+
37+
def test_static_system_message_falls_back_to_template_when_none() -> None:
38+
agent = Agent(llm=_make_llm(), tools=[])
39+
# The default template renders a non-empty string
40+
assert len(agent.static_system_message) > 0
41+
assert agent.static_system_message != ""
42+
43+
44+
# --- mutual-exclusivity validation ---
45+
46+
47+
def test_system_prompt_and_custom_filename_are_mutually_exclusive() -> None:
48+
with pytest.raises(ValueError, match="Cannot set both"):
49+
Agent(
50+
llm=_make_llm(),
51+
tools=[],
52+
system_prompt="inline",
53+
system_prompt_filename="custom.j2",
54+
)
55+
56+
57+
def test_system_prompt_with_default_filename_is_ok() -> None:
58+
"""system_prompt + the default filename should be accepted."""
59+
agent = Agent(
60+
llm=_make_llm(),
61+
tools=[],
62+
system_prompt="inline",
63+
system_prompt_filename="system_prompt.j2",
64+
)
65+
assert agent.system_prompt == "inline"
66+
assert agent.static_system_message == "inline"
67+
68+
69+
# --- serialization round-trip ---
70+
71+
72+
def test_system_prompt_survives_json_round_trip() -> None:
73+
agent = Agent(llm=_make_llm(), tools=[], system_prompt="ROUND TRIP")
74+
agent_json = agent.model_dump_json()
75+
restored = AgentBase.model_validate_json(agent_json)
76+
assert isinstance(restored, Agent)
77+
assert restored.system_prompt == "ROUND TRIP"
78+
assert restored.static_system_message == "ROUND TRIP"
79+
80+
81+
def test_system_prompt_none_survives_json_round_trip() -> None:
82+
agent = Agent(llm=_make_llm(), tools=[])
83+
agent_json = agent.model_dump_json()
84+
restored = AgentBase.model_validate_json(agent_json)
85+
assert isinstance(restored, Agent)
86+
assert restored.system_prompt is None

0 commit comments

Comments
 (0)