Skip to content

Commit 85ddc2b

Browse files
authored
Merge branch 'main' into feat/pr-review-sub-agent-delegation
2 parents 731aa75 + 1f6371d commit 85ddc2b

File tree

7 files changed

+195
-94
lines changed

7 files changed

+195
-94
lines changed

.github/scripts/check_sdk_api_breakage.py

Lines changed: 79 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ class member must have been marked deprecated in the *previous* release using
3333
import ast
3434
import json
3535
import os
36-
import re
3736
import subprocess
3837
import sys
3938
import tomllib
@@ -376,71 +375,88 @@ def ensure_griffe() -> None:
376375
raise SystemExit(1)
377376

378377

379-
def _strip_balanced_param(text: str, param: str) -> str:
380-
"""Remove a keyword parameter whose value may contain nested delimiters.
378+
FIELD_METADATA_KWARGS = frozenset(
379+
{
380+
"deprecated",
381+
"description",
382+
"examples",
383+
"json_schema_extra",
384+
"title",
385+
}
386+
)
381387

382-
Handles values like ``json_schema_extra={'key': {'nested': True}}`` where a
383-
simple regex cannot reliably match the balanced braces/parens/brackets.
384388

385-
Returns *text* with the ``param=<value>`` fragment (and any surrounding
386-
comma) removed.
387-
"""
388-
pattern = re.compile(rf",?\s*{re.escape(param)}\s*=\s*")
389-
match = pattern.search(text)
390-
if not match:
391-
return text
392-
393-
start = match.start()
394-
pos = match.end()
395-
if pos >= len(text):
396-
return text
397-
398-
# Track balanced delimiters to find where the value ends.
399-
openers = {"(": ")", "[": "]", "{": "}"}
400-
closers = {")", "]", "}"}
401-
stack: list[str] = []
389+
def _escape_newlines_in_string_literals(text: str) -> str:
390+
"""Escape literal newlines that appear inside quoted string literals."""
391+
chars: list[str] = []
402392
in_string: str | None = None
393+
escaped = False
394+
395+
for ch in text:
396+
if in_string is None:
397+
chars.append(ch)
398+
if ch in {"'", '"'}:
399+
in_string = ch
400+
continue
401+
402+
if escaped:
403+
chars.append(ch)
404+
escaped = False
405+
continue
403406

404-
while pos < len(text):
405-
ch = text[pos]
406-
407-
# Handle string literals (skip their contents).
408-
if in_string:
409-
if ch == "\\" and pos + 1 < len(text):
410-
pos += 2
411-
continue
412-
if ch == in_string:
413-
in_string = None
414-
pos += 1
407+
if ch == "\\":
408+
chars.append(ch)
409+
escaped = True
415410
continue
416411

417-
if ch in ("'", '"'):
418-
in_string = ch
419-
pos += 1
412+
if ch == in_string:
413+
chars.append(ch)
414+
in_string = None
420415
continue
421416

422-
if ch in openers:
423-
stack.append(openers[ch])
424-
pos += 1
417+
if ch == "\n":
418+
chars.append("\\n")
425419
continue
426420

427-
if ch in closers:
428-
if stack:
429-
stack.pop()
430-
pos += 1
431-
if not stack:
432-
break
433-
continue
434-
# Unmatched closer — end of value.
435-
break
421+
chars.append(ch)
422+
423+
return "".join(chars)
424+
425+
426+
def _parse_field_call(value: object) -> ast.Call | None:
427+
"""Parse a stringified Pydantic ``Field(...)`` value into an AST call."""
428+
try:
429+
expr = ast.parse(
430+
_escape_newlines_in_string_literals(str(value)),
431+
mode="eval",
432+
).body
433+
except SyntaxError:
434+
return None
435+
436+
if not isinstance(expr, ast.Call):
437+
return None
438+
439+
func = expr.func
440+
if isinstance(func, ast.Name):
441+
func_name = func.id
442+
elif isinstance(func, ast.Attribute):
443+
func_name = func.attr
444+
else:
445+
return None
436446

437-
# At depth 0, a comma or closing paren ends the value.
438-
if not stack and ch in (",", ")"):
439-
break
447+
if func_name != "Field":
448+
return None
440449

441-
pos += 1
450+
return expr
442451

443-
return text[:start] + text[pos:]
452+
453+
def _filter_field_metadata_kwargs(call: ast.Call) -> ast.Call:
454+
"""Return a copy of a ``Field(...)`` call without metadata-only kwargs."""
455+
return ast.Call(
456+
func=call.func,
457+
args=call.args,
458+
keywords=[kw for kw in call.keywords if kw.arg not in FIELD_METADATA_KWARGS],
459+
)
444460

445461

446462
def _is_field_metadata_only_change(old_val: object, new_val: object) -> bool:
@@ -453,43 +469,18 @@ def _is_field_metadata_only_change(old_val: object, new_val: object) -> bool:
453469
Returns:
454470
True if both values are Field() calls and only metadata parameters differ.
455471
"""
456-
old_str = str(old_val)
457-
new_str = str(new_val)
458-
459-
if not (old_str.startswith("Field(") and new_str.startswith("Field(")):
472+
old_call = _parse_field_call(old_val)
473+
new_call = _parse_field_call(new_val)
474+
if old_call is None or new_call is None:
460475
return False
461476

462-
# Simple metadata parameters whose values are always plain quoted strings
463-
# or simple literals.
464-
# See https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.Field
465-
simple_metadata_patterns = {
466-
"description": r'([\'"])([^\'"]*?)\1',
467-
"title": r'([\'"])([^\'"]*?)\1',
468-
"examples": r'([\'"])([^\'"]*?)\1',
469-
"deprecated": r"(?:True|False|None|'[^']*'|\"[^\"]*\")",
470-
}
471-
472-
# Parameters whose values can be complex nested structures (dicts, function
473-
# calls, etc.) and need balanced-delimiter parsing instead of a regex.
474-
balanced_params = ("json_schema_extra",)
475-
476-
def _normalize(value: str) -> str:
477-
normalized = value
478-
479-
for param, value_pattern in simple_metadata_patterns.items():
480-
pattern = rf",?\s*{param}\s*=\s*{value_pattern}"
481-
normalized = re.sub(pattern, "", normalized)
482-
483-
for param in balanced_params:
484-
normalized = _strip_balanced_param(normalized, param)
485-
486-
normalized = re.sub(r"\(\s*,", "(", normalized)
487-
normalized = re.sub(r",\s*\)", ")", normalized)
488-
normalized = re.sub(r",\s*,", ", ", normalized)
489-
normalized = re.sub(r"\s+", " ", normalized)
490-
return normalized.strip()
491-
492-
return _normalize(old_str) == _normalize(new_str)
477+
return ast.dump(
478+
_filter_field_metadata_kwargs(old_call),
479+
include_attributes=False,
480+
) == ast.dump(
481+
_filter_field_metadata_kwargs(new_call),
482+
include_attributes=False,
483+
)
493484

494485

495486
def _member_deprecation_metadata(

.github/workflows/pr-review-evaluation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
steps:
2929
- name: Download review trace artifact
3030
id: download-trace
31-
uses: dawidd6/action-download-artifact@v19
31+
uses: dawidd6/action-download-artifact@v20
3232
continue-on-error: true
3333
with:
3434
workflow: pr-review-by-openhands.yml

.github/workflows/qa-changes-evaluation.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
steps:
2929
- name: Download QA trace artifact
3030
id: download-trace
31-
uses: dawidd6/action-download-artifact@v19
31+
uses: dawidd6/action-download-artifact@v20
3232
continue-on-error: true
3333
with:
3434
workflow: qa-changes-by-openhands.yml

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ consult each relevant package-level AGENTS.md.
140140
Pydantic `Field(...)` declarations as non-breaking, including adding,
141141
removing, or editing `description`, `title`, `examples`,
142142
`json_schema_extra`, and `deprecated` kwargs.
143+
- The SDK API breakage checker compares stringified `Field(...)` values by
144+
parsing them as Python expressions after escaping literal newlines inside
145+
quoted strings; this avoids false positives on multiline descriptions that
146+
include embedded quotes like `'security_policy.j2'`.
143147
- For public REST APIs, read
144148
[openhands-agent-server/AGENTS.md](openhands-agent-server/AGENTS.md).
145149
REST contract breaks need a deprecation notice and a runway of

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,14 +158,14 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
158158
"- An absolute path (e.g., '/path/to/custom_prompt.j2')"
159159
),
160160
)
161-
security_policy_filename: str | None = Field(
161+
security_policy_filename: str = Field(
162162
default="security_policy.j2",
163163
description=(
164-
"Security policy template filename. Can be:\n"
164+
"Security policy template filename. Can be either:\n"
165165
"- A relative filename (e.g., 'security_policy.j2') loaded from the "
166166
"agent's prompts directory\n"
167167
"- An absolute path (e.g., '/path/to/custom_security_policy.j2')\n"
168-
"- Empty string or None to disable security policy"
168+
"- Empty string to disable security policy"
169169
),
170170
)
171171
system_prompt_kwargs: dict[str, object] = Field(
@@ -179,6 +179,11 @@ class AgentBase(DiscriminatedUnionMixin, ABC):
179179
def _validate_system_prompt_fields(cls, data: Any) -> Any:
180180
if not isinstance(data, dict):
181181
return data
182+
if (
183+
"security_policy_filename" in data
184+
and data["security_policy_filename"] is None
185+
):
186+
data["security_policy_filename"] = ""
182187
has_inline = data.get("system_prompt") is not None
183188
has_custom_filename = (
184189
"system_prompt_filename" in data

tests/cross/test_check_sdk_api_breakage.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,27 @@ def test_is_field_metadata_only_change_long_description():
555555
assert _is_field_metadata_only_change(old, new) is True
556556

557557

558+
def test_is_field_metadata_only_change_multiline_description_with_quotes():
559+
"""Multiline descriptions with embedded quotes are metadata-only changes."""
560+
old = (
561+
"Field(default='security_policy.j2', description=\"Security policy "
562+
"template filename. Can be either:\n"
563+
"- A relative filename (e.g., 'security_policy.j2') loaded from the "
564+
"agent's prompts directory\n"
565+
"- An absolute path (e.g., '/path/to/custom_security_policy.j2')\")"
566+
)
567+
new = (
568+
"Field(default='security_policy.j2', description=\"Security policy "
569+
"template filename. Can be either:\n"
570+
"- A relative filename (e.g., 'security_policy.j2') loaded from the "
571+
"agent's prompts directory\n"
572+
"- An absolute path (e.g., '/path/to/custom_security_policy.j2')\n"
573+
'- Empty string to disable security policy")'
574+
)
575+
576+
assert _is_field_metadata_only_change(old, new) is True
577+
578+
558579
def test_is_field_metadata_only_change_deprecated_bool_only():
559580
"""Changing only Field deprecated metadata is detected as metadata-only."""
560581
old = "Field(default=False, deprecated=False)"
@@ -699,7 +720,68 @@ def test_field_description_change_is_not_breaking(tmp_path):
699720
new_root,
700721
_SDK_CFG,
701722
)
702-
# Field description changes should NOT count as breaking
723+
assert total_breaks == 0
724+
assert undeprecated == 0
725+
726+
727+
def test_field_multiline_description_with_quotes_is_not_breaking(tmp_path):
728+
"""Multiline descriptions with embedded quotes should not be breaking."""
729+
old_pkg = _write_pkg_init(tmp_path, "old", ["Config"])
730+
new_pkg = _write_pkg_init(tmp_path, "new", ["Config"])
731+
732+
old_init = old_pkg / "__init__.py"
733+
new_init = new_pkg / "__init__.py"
734+
735+
old_init.write_text(
736+
old_init.read_text()
737+
+ "\nfrom pydantic import BaseModel, Field\n\n"
738+
+ "class Config(BaseModel):\n"
739+
+ " policy: str = Field(\n"
740+
+ " default='security_policy.j2',\n"
741+
+ " description=(\n"
742+
+ ' "Security policy template filename. Can be either:\\n"\n'
743+
+ (
744+
' "- A relative filename (e.g., '
745+
"'security_policy.j2') loaded from \"\n"
746+
)
747+
+ ' "the agent\'s prompts directory\\n"\n'
748+
+ (
749+
' "- An absolute path (e.g., '
750+
"'/path/to/custom_security_policy.j2')\"\n"
751+
)
752+
+ " ),\n"
753+
+ " )\n"
754+
)
755+
new_init.write_text(
756+
new_init.read_text()
757+
+ "\nfrom pydantic import BaseModel, Field\n\n"
758+
+ "class Config(BaseModel):\n"
759+
+ " policy: str = Field(\n"
760+
+ " default='security_policy.j2',\n"
761+
+ " description=(\n"
762+
+ ' "Security policy template filename. Can be either:\\n"\n'
763+
+ (
764+
' "- A relative filename (e.g., '
765+
"'security_policy.j2') loaded from \"\n"
766+
)
767+
+ ' "the agent\'s prompts directory\\n"\n'
768+
+ (
769+
' "- An absolute path (e.g., '
770+
"'/path/to/custom_security_policy.j2')\\n\"\n"
771+
)
772+
+ ' "- Empty string to disable security policy"\n'
773+
+ " ),\n"
774+
+ " )\n"
775+
)
776+
777+
old_root = griffe.load("openhands.sdk", search_paths=[str(tmp_path / "old")])
778+
new_root = griffe.load("openhands.sdk", search_paths=[str(tmp_path / "new")])
779+
780+
total_breaks, undeprecated = _prod._compute_breakages(
781+
old_root,
782+
new_root,
783+
_SDK_CFG,
784+
)
703785
assert total_breaks == 0
704786
assert undeprecated == 0
705787

tests/sdk/agent/test_security_policy_integration.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,25 @@ def test_security_policy_in_system_message():
6565
assert "AI assistant (OpenHands)" not in system_message
6666

6767

68+
def test_none_security_policy_filename_disables_policy_without_null_public_value():
69+
"""Test that None input disables the policy without exposing a null contract."""
70+
agent = Agent.model_validate(
71+
{
72+
"llm": LLM(
73+
usage_id="test-llm",
74+
model="test-model",
75+
api_key=SecretStr("test-key"),
76+
base_url="http://test",
77+
),
78+
"security_policy_filename": None,
79+
}
80+
)
81+
82+
assert agent.security_policy_filename == ""
83+
assert agent.model_dump()["security_policy_filename"] == ""
84+
assert "🔐 Security Policy" not in agent.static_system_message
85+
86+
6887
def test_custom_security_policy_in_system_message():
6988
"""Test that custom security policy filename is used in system message."""
7089
# Create a temporary directory for test files

0 commit comments

Comments
 (0)