Skip to content

Commit 34bfa99

Browse files
authored
Merge branch 'main' into vasco/fix-list-str-coercion
2 parents c9b12aa + ea4e0cc commit 34bfa99

14 files changed

Lines changed: 555 additions & 170 deletions

File tree

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

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -502,26 +502,58 @@ def _resolved_template_kwargs(self) -> dict[str, object]:
502502
template_kwargs["model_variant"] = spec.variant
503503
return template_kwargs
504504

505-
def _build_prompt_context(self) -> PromptContext:
505+
def _build_prompt_context(
506+
self,
507+
additional_secret_infos: list[dict[str, str | None]] | None = None,
508+
) -> PromptContext:
506509
"""Frozen :class:`PromptContext` snapshot for this agent.
507510
508511
``template_kwargs`` is resolved by the shared
509512
:meth:`_resolved_template_kwargs`; the other fields snapshot
510-
per-conversation signals.
513+
per-conversation signals. The dynamic-tier fields reuse
514+
``AgentContext._resolve_dynamic_data`` so skills are model-gated and
515+
secrets merged exactly as ``get_system_message_suffix`` does;
516+
``additional_secret_infos`` mirrors ``get_dynamic_context(state)``.
511517
"""
512518
agent_context = self.agent_context
519+
# Mirror get_dynamic_context's temp-context path: with no agent_context but
520+
# conversation secrets present, the legacy renderer resolves a default
521+
# AgentContext() (which carries a default current_datetime), so its dynamic
522+
# block advertises the secrets *and* a <CURRENT_DATETIME>. Resolve the same
523+
# default here so the registry reproduces both blocks, not just secrets.
524+
if agent_context is None and additional_secret_infos:
525+
agent_context = AgentContext()
526+
527+
now: str | None = None
528+
skill_names: tuple[str, ...] = ()
529+
secret_names: tuple[str, ...] = ()
530+
repo_skills: tuple[tuple[str, str], ...] = ()
531+
available_skills_prompt: str | None = None
532+
custom_suffix: str | None = None
533+
secret_infos: tuple[tuple[str, str | None], ...] = ()
534+
513535
if agent_context is not None:
514-
now = agent_context.get_formatted_datetime()
536+
data = agent_context._resolve_dynamic_data(
537+
self.llm.model,
538+
self.llm.model_canonical_name,
539+
additional_secret_infos,
540+
)
541+
# Reuse the shared resolver's formatted datetime rather than re-deriving
542+
# it: get_system_message_suffix renders this exact string, so the registry
543+
# must too (a rounded copy would break byte-for-byte parity for callers
544+
# that pass a datetime object instead of a pre-formatted string).
545+
now = data.formatted_datetime
515546
skill_names = tuple(skill.name for skill in agent_context.skills)
516-
secret_names = tuple(
517-
info["name"]
518-
for info in agent_context.get_secret_infos()
519-
if info["name"] is not None
547+
repo_skills = tuple((s.name, s.content) for s in data.repo_skills)
548+
available_skills_prompt = data.available_skills_prompt or None
549+
custom_suffix = agent_context.system_message_suffix or None
550+
secret_infos = tuple(
551+
(info["name"] or "", info["description"]) for info in data.secret_infos
520552
)
521-
else:
522-
now = None
523-
skill_names = ()
524-
secret_names = ()
553+
# Derive names from the resolver's merged secret_infos instead of a
554+
# second get_secret_infos() walk; this now includes registry-provided
555+
# secrets (additional_secret_infos), matching what <CUSTOM_SECRETS> shows.
556+
secret_names = tuple(name for name, _ in secret_infos if name)
525557

526558
return PromptContext(
527559
template_kwargs=self._resolved_template_kwargs(),
@@ -531,6 +563,10 @@ def _build_prompt_context(self) -> PromptContext:
531563
now=now,
532564
skill_names=skill_names,
533565
secret_names=secret_names,
566+
repo_skills=repo_skills,
567+
available_skills_prompt=available_skills_prompt,
568+
custom_suffix=custom_suffix,
569+
secret_infos=secret_infos,
534570
)
535571

536572
@property

openhands-sdk/openhands/sdk/context/agent_context.py

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pathlib
44
from collections.abc import Mapping
55
from datetime import datetime
6-
from typing import Any
6+
from typing import Any, NamedTuple
77

88
from pydantic import (
99
BaseModel,
@@ -39,6 +39,16 @@
3939
PROMPT_DIR = pathlib.Path(__file__).parent / "prompts" / "templates"
4040

4141

42+
class ResolvedDynamicData(NamedTuple):
43+
"""Dynamic-tier inputs resolved once, shared by the legacy ``.j2`` renderer and
44+
the section registry (skills gated by model family, secrets merged)."""
45+
46+
repo_skills: list[Skill]
47+
available_skills_prompt: str
48+
secret_infos: list[dict[str, str | None]]
49+
formatted_datetime: str | None
50+
51+
4252
class AgentContext(BaseModel):
4353
"""Central structure for managing prompt extension.
4454
@@ -300,6 +310,40 @@ def get_system_message_suffix(
300310
- Legacy with trigger=None: Full content in <REPO_CONTEXT> (always active)
301311
- Legacy with triggers: Listed in <available_skills>, injected on trigger
302312
"""
313+
data = self._resolve_dynamic_data(
314+
llm_model, llm_model_canonical, additional_secret_infos
315+
)
316+
has_content = (
317+
data.repo_skills
318+
or self.system_message_suffix
319+
or data.secret_infos
320+
or data.available_skills_prompt
321+
or data.formatted_datetime
322+
)
323+
if has_content:
324+
formatted_text = render_template(
325+
prompt_dir=str(PROMPT_DIR),
326+
template_name="system_message_suffix.j2",
327+
repo_skills=data.repo_skills,
328+
system_message_suffix=self.system_message_suffix or "",
329+
secret_infos=data.secret_infos,
330+
available_skills_prompt=data.available_skills_prompt,
331+
current_datetime=data.formatted_datetime,
332+
).strip()
333+
return formatted_text
334+
elif self.system_message_suffix and self.system_message_suffix.strip():
335+
return self.system_message_suffix.strip()
336+
return None
337+
338+
def _resolve_dynamic_data(
339+
self,
340+
llm_model: str | None = None,
341+
llm_model_canonical: str | None = None,
342+
additional_secret_infos: list[dict[str, str | None]] | None = None,
343+
) -> ResolvedDynamicData:
344+
"""Resolve the dynamic-tier inputs shared by :meth:`get_system_message_suffix`
345+
and the section registry: model-gated repo skills, the available-skills
346+
prompt, merged secret infos, and the formatted datetime. Pure (no render)."""
303347
repo_skills, available_skills = self._partition_skills()
304348

305349
# Gate vendor-specific repo skills based on model family.
@@ -323,45 +367,28 @@ def get_system_message_suffix(
323367

324368
logger.debug(f"Loaded {len(repo_skills)} repository skills: {repo_skills}")
325369

326-
# Generate available skills prompt
327370
available_skills_prompt = ""
328371
if available_skills:
329372
available_skills_prompt = to_prompt(available_skills)
330373
logger.debug(
331374
f"Generated available skills prompt for {len(available_skills)} skills"
332375
)
333376

334-
# Build the workspace context information
335-
# Merge agent_context secrets with additional secrets from registry
377+
# Merge agent_context secrets with additional secrets from the registry
378+
# (additional override by name).
336379
secret_infos = self.get_secret_infos()
337380
if additional_secret_infos:
338-
# Merge: additional secrets override agent_context secrets by name
339381
secret_dict = {s["name"]: s for s in secret_infos}
340382
for additional in additional_secret_infos:
341383
secret_dict[additional["name"]] = additional
342384
secret_infos = list(secret_dict.values())
343-
formatted_datetime = self.get_formatted_datetime()
344-
has_content = (
345-
repo_skills
346-
or self.system_message_suffix
347-
or secret_infos
348-
or available_skills_prompt
349-
or formatted_datetime
385+
386+
return ResolvedDynamicData(
387+
repo_skills=repo_skills,
388+
available_skills_prompt=available_skills_prompt,
389+
secret_infos=secret_infos,
390+
formatted_datetime=self.get_formatted_datetime(),
350391
)
351-
if has_content:
352-
formatted_text = render_template(
353-
prompt_dir=str(PROMPT_DIR),
354-
template_name="system_message_suffix.j2",
355-
repo_skills=repo_skills,
356-
system_message_suffix=self.system_message_suffix or "",
357-
secret_infos=secret_infos,
358-
available_skills_prompt=available_skills_prompt,
359-
current_datetime=formatted_datetime,
360-
).strip()
361-
return formatted_text
362-
elif self.system_message_suffix and self.system_message_suffix.strip():
363-
return self.system_message_suffix.strip()
364-
return None
365392

366393
def validate_acp_compatibility(self) -> None:
367394
"""Raise if this context uses fields unsupported by ACP prompt mode.

openhands-sdk/openhands/sdk/context/prompts/default_registry.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@
77
"""
88

99
from openhands.sdk.context.prompts.registry import PromptRegistry
10+
from openhands.sdk.context.prompts.sections.dynamic import (
11+
AvailableSkillsSection,
12+
CustomSecretsSection,
13+
CustomSuffixSection,
14+
DateTimeSection,
15+
RepoContextSection,
16+
)
1017
from openhands.sdk.context.prompts.sections.static import (
1118
BrowserSection,
1219
CodeQualitySection,
@@ -53,4 +60,10 @@ def build_default_registry() -> PromptRegistry:
5360
r.register(TroubleshootingSection())
5461
r.register(ProcessManagementSection())
5562
r.register(ModelSpecificSection()) # guard: model_family resolved
63+
# dynamic tier -- ported verbatim from system_message_suffix.j2 (#3610)
64+
r.register(DateTimeSection())
65+
r.register(RepoContextSection()) # guard: gated repo skills present
66+
r.register(AvailableSkillsSection()) # guard: available_skills_prompt
67+
r.register(CustomSuffixSection()) # guard: system_message_suffix
68+
r.register(CustomSecretsSection()) # guard: secret_infos present
5669
return r

openhands-sdk/openhands/sdk/context/prompts/section.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ class PromptContext(BaseModel):
6868
now: str | None = None
6969
skill_names: tuple[str, ...] = Field(default_factory=tuple)
7070
secret_names: tuple[str, ...] = Field(default_factory=tuple)
71+
# Resolved dynamic-tier data (skills gated + secrets merged before assembly),
72+
# consumed by the dynamic sections. ``repo_skills`` is (name, content) pairs;
73+
# ``secret_infos`` is (name, description) pairs.
74+
repo_skills: tuple[tuple[str, str], ...] = Field(default_factory=tuple)
75+
available_skills_prompt: str | None = None
76+
custom_suffix: str | None = None
77+
secret_infos: tuple[tuple[str, str | None], ...] = Field(default_factory=tuple)
7178

7279
@field_validator("template_kwargs", mode="after")
7380
@classmethod

openhands-sdk/openhands/sdk/context/prompts/sections/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
in :mod:`.static`.
77
"""
88

9+
from openhands.sdk.context.prompts.sections.dynamic import (
10+
AvailableSkillsSection,
11+
CustomSecretsSection,
12+
CustomSuffixSection,
13+
DateTimeSection,
14+
RepoContextSection,
15+
)
916
from openhands.sdk.context.prompts.sections.static import (
1017
BrowserSection,
1118
CodeQualitySection,
@@ -29,8 +36,12 @@
2936

3037

3138
__all__ = [
39+
"AvailableSkillsSection",
3240
"BrowserSection",
3341
"CodeQualitySection",
42+
"CustomSecretsSection",
43+
"CustomSuffixSection",
44+
"DateTimeSection",
3445
"EfficiencySection",
3546
"EnvironmentSetupSection",
3647
"ExternalServicesSection",
@@ -40,6 +51,7 @@
4051
"ProblemSolvingSection",
4152
"ProcessManagementSection",
4253
"PullRequestsSection",
54+
"RepoContextSection",
4355
"RoleSection",
4456
"SecurityRiskAssessmentSection",
4557
"SecuritySection",

0 commit comments

Comments
 (0)