-
Notifications
You must be signed in to change notification settings - Fork 213
Expand file tree
/
Copy pathagent_context.py
More file actions
337 lines (302 loc) · 13.7 KB
/
agent_context.py
File metadata and controls
337 lines (302 loc) · 13.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
from __future__ import annotations
import pathlib
from collections.abc import Mapping
from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator
from openhands.sdk.context.prompts import render_template
from openhands.sdk.llm import Message, TextContent
from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec
from openhands.sdk.logger import get_logger
from openhands.sdk.secret import SecretSource, SecretValue
from openhands.sdk.skills import (
Skill,
SkillKnowledge,
load_available_skills,
to_prompt,
)
from openhands.sdk.skills.skill import DEFAULT_MARKETPLACE_PATH
logger = get_logger(__name__)
PROMPT_DIR = pathlib.Path(__file__).parent / "prompts" / "templates"
class AgentContext(BaseModel):
"""Central structure for managing prompt extension.
AgentContext unifies all the contextual inputs that shape how the system
extends and interprets user prompts. It combines both static environment
details and dynamic, user-activated extensions from skills.
Specifically, it provides:
- **Repository context / Repo Skills**: Information about the active codebase,
branches, and repo-specific instructions contributed by repo skills.
- **Runtime context**: Current execution environment (hosts, working
directory, secrets, date, etc.).
- **Conversation instructions**: Optional task- or channel-specific rules
that constrain or guide the agent’s behavior across the session.
- **Knowledge Skills**: Extensible components that can be triggered by user input
to inject knowledge or domain-specific guidance.
Together, these elements make AgentContext the primary container responsible
for assembling, formatting, and injecting all prompt-relevant context into
LLM interactions.
""" # noqa: E501
skills: list[Skill] = Field(
default_factory=list,
description="List of available skills that can extend the user's input.",
)
system_message_suffix: str | None = Field(
default=None, description="Optional suffix to append to the system prompt."
)
user_message_suffix: str | None = Field(
default=None, description="Optional suffix to append to the user's message."
)
load_user_skills: bool = Field(
default=False,
description=(
"Whether to automatically load user skills from ~/.openhands/skills/ "
"and ~/.openhands/microagents/ (for backward compatibility). "
),
)
load_public_skills: bool = Field(
default=False,
description=(
"Whether to automatically load skills from the public OpenHands "
"skills repository at https://github.com/OpenHands/extensions. "
"This allows you to get the latest skills without SDK updates."
),
)
marketplace_path: str | None = Field(
default=DEFAULT_MARKETPLACE_PATH,
description=(
"Relative marketplace JSON path within the public skills repository. "
"Set to None to load all public skills without marketplace filtering."
),
)
secrets: Mapping[str, SecretValue] | None = Field(
default=None,
description=(
"Dictionary mapping secret keys to values or secret sources. "
"Secrets are used for authentication and sensitive data handling. "
"Values can be either strings or SecretSource instances "
"(str | SecretSource)."
),
)
current_datetime: datetime | str | None = Field(
default_factory=datetime.now,
description=(
"Current date and time information to provide to the agent. "
"Can be a datetime object (which will be formatted as ISO 8601) "
"or a pre-formatted string. When provided, this information is "
"included in the system prompt to give the agent awareness of "
"the current time context. Defaults to the current datetime."
),
)
@field_validator("skills")
@classmethod
def _validate_skills(cls, v: list[Skill], _info):
if not v:
return v
# Check for duplicate skill names
seen_names = set()
for skill in v:
if skill.name in seen_names:
raise ValueError(f"Duplicate skill name found: {skill.name}")
seen_names.add(skill.name)
return v
@model_validator(mode="after")
def _load_auto_skills(self):
"""Load user and/or public skills if enabled."""
if not self.load_user_skills and not self.load_public_skills:
return self
auto_skills = load_available_skills(
work_dir=None,
include_user=self.load_user_skills,
include_project=False,
include_public=self.load_public_skills,
marketplace_path=self.marketplace_path,
)
existing_names = {skill.name for skill in self.skills}
for name, skill in auto_skills.items():
if name not in existing_names:
self.skills.append(skill)
else:
logger.warning(
f"Skipping auto-loaded skill '{name}' (already in explicit skills)"
)
return self
def get_secret_infos(self) -> list[dict[str, str | None]]:
"""Get secret information (name and description) from the secrets field.
Returns:
List of dictionaries with 'name' and 'description' keys.
Returns an empty list if no secrets are configured.
Description will be None if not available.
"""
if not self.secrets:
return []
secret_infos: list[dict[str, str | None]] = []
for name, secret_value in self.secrets.items():
description = None
if isinstance(secret_value, SecretSource):
description = secret_value.description
secret_infos.append({"name": name, "description": description})
return secret_infos
def get_formatted_datetime(self) -> str | None:
"""Get formatted datetime string for inclusion in prompts.
Returns:
Formatted datetime string, or None if current_datetime is not set.
If current_datetime is a datetime object, it's formatted as ISO 8601.
If current_datetime is already a string, it's returned as-is.
"""
if self.current_datetime is None:
return None
if isinstance(self.current_datetime, datetime):
return self.current_datetime.isoformat()
return self.current_datetime
def get_system_message_suffix(
self,
llm_model: str | None = None,
llm_model_canonical: str | None = None,
additional_secret_infos: list[dict[str, str | None]] | None = None,
) -> str | None:
"""Get the system message with repo skill content and custom suffix.
Custom suffix can typically includes:
- Repository information (repo name, branch name, PR number, etc.)
- Runtime information (e.g., available hosts, current date)
- Conversation instructions (e.g., user preferences, task details)
- Repository-specific instructions (collected from repo skills)
- Available skills list (for AgentSkills-format and triggered skills)
Args:
llm_model: Optional LLM model name for vendor-specific skill filtering.
llm_model_canonical: Optional canonical LLM model name.
additional_secret_infos: Optional list of additional secret info dicts
(with 'name' and 'description' keys) to merge with agent_context
secrets. Typically passed from conversation's secret_registry.
Skill categorization:
- AgentSkills-format (SKILL.md): Always in <available_skills> (progressive
disclosure). If has triggers, content is ALSO auto-injected on trigger
in user prompts.
- Legacy with trigger=None: Full content in <REPO_CONTEXT> (always active)
- Legacy with triggers: Listed in <available_skills>, injected on trigger
"""
# Categorize skills based on format and trigger:
# - AgentSkills-format: always in available_skills (progressive disclosure)
# - Legacy: trigger=None -> REPO_CONTEXT, else -> available_skills
repo_skills: list[Skill] = []
available_skills: list[Skill] = []
for s in self.skills:
if s.is_agentskills_format:
# AgentSkills: always list (triggers also auto-inject via
# get_user_message_suffix)
available_skills.append(s)
elif s.trigger is None:
# Legacy OpenHands: no trigger = full content in REPO_CONTEXT
repo_skills.append(s)
else:
# Legacy OpenHands: has trigger = list in available_skills
available_skills.append(s)
# Gate vendor-specific repo skills based on model family.
if llm_model or llm_model_canonical:
spec = get_model_prompt_spec(llm_model or "", llm_model_canonical)
family = (spec.family or "").lower()
if family:
filtered: list[Skill] = []
for s in repo_skills:
n = (s.name or "").lower()
if n == "claude" and not (
"anthropic" in family or "claude" in family
):
continue
if n == "gemini" and not (
"gemini" in family or "google_gemini" in family
):
continue
filtered.append(s)
repo_skills = filtered
logger.debug(f"Loaded {len(repo_skills)} repository skills: {repo_skills}")
# Generate available skills prompt
available_skills_prompt = ""
if available_skills:
available_skills_prompt = to_prompt(available_skills)
logger.debug(
f"Generated available skills prompt for {len(available_skills)} skills"
)
# Build the workspace context information
# Merge agent_context secrets with additional secrets from registry
secret_infos = self.get_secret_infos()
if additional_secret_infos:
# Merge: additional secrets override agent_context secrets by name
secret_dict = {s["name"]: s for s in secret_infos}
for additional in additional_secret_infos:
secret_dict[additional["name"]] = additional
secret_infos = list(secret_dict.values())
formatted_datetime = self.get_formatted_datetime()
has_content = (
repo_skills
or self.system_message_suffix
or secret_infos
or available_skills_prompt
or formatted_datetime
)
if has_content:
formatted_text = render_template(
prompt_dir=str(PROMPT_DIR),
template_name="system_message_suffix.j2",
repo_skills=repo_skills,
system_message_suffix=self.system_message_suffix or "",
secret_infos=secret_infos,
available_skills_prompt=available_skills_prompt,
current_datetime=formatted_datetime,
).strip()
return formatted_text
elif self.system_message_suffix and self.system_message_suffix.strip():
return self.system_message_suffix.strip()
return None
def get_user_message_suffix(
self, user_message: Message, skip_skill_names: list[str]
) -> tuple[TextContent, list[str]] | None:
"""Augment the user’s message with knowledge recalled from skills.
This works by:
- Extracting the text content of the user message
- Matching skill triggers against the query
- Returning formatted knowledge and triggered skill names if relevant skills were triggered
""" # noqa: E501
user_message_suffix = None
if self.user_message_suffix and self.user_message_suffix.strip():
user_message_suffix = self.user_message_suffix.strip()
query = "\n".join(
c.text for c in user_message.content if isinstance(c, TextContent)
).strip()
recalled_knowledge: list[SkillKnowledge] = []
# skip empty queries, but still return user_message_suffix if it exists
if not query:
if user_message_suffix:
return TextContent(text=user_message_suffix), []
return None
# Search for skill triggers in the query
for skill in self.skills:
if not isinstance(skill, Skill):
continue
trigger = skill.match_trigger(query)
if trigger and skill.name not in skip_skill_names:
logger.info(
"Skill '%s' triggered by keyword '%s'",
skill.name,
trigger,
)
recalled_knowledge.append(
SkillKnowledge(
name=skill.name,
trigger=trigger,
content=skill.content,
location=skill.source,
)
)
if recalled_knowledge:
formatted_skill_text = render_template(
prompt_dir=str(PROMPT_DIR),
template_name="skill_knowledge_info.j2",
triggered_agents=recalled_knowledge,
)
if user_message_suffix:
formatted_skill_text += "\n" + user_message_suffix
return TextContent(text=formatted_skill_text), [
k.name for k in recalled_knowledge
]
if user_message_suffix:
return TextContent(text=user_message_suffix), []
return None