openhands-sdk 1.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/__init__.py +111 -0
- openhands/sdk/agent/__init__.py +8 -0
- openhands/sdk/agent/agent.py +650 -0
- openhands/sdk/agent/base.py +457 -0
- openhands/sdk/agent/prompts/in_context_learning_example.j2 +169 -0
- openhands/sdk/agent/prompts/in_context_learning_example_suffix.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/anthropic_claude.j2 +3 -0
- openhands/sdk/agent/prompts/model_specific/google_gemini.j2 +1 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5-codex.j2 +2 -0
- openhands/sdk/agent/prompts/model_specific/openai_gpt/gpt-5.j2 +3 -0
- openhands/sdk/agent/prompts/security_policy.j2 +22 -0
- openhands/sdk/agent/prompts/security_risk_assessment.j2 +21 -0
- openhands/sdk/agent/prompts/self_documentation.j2 +15 -0
- openhands/sdk/agent/prompts/system_prompt.j2 +132 -0
- openhands/sdk/agent/prompts/system_prompt_interactive.j2 +14 -0
- openhands/sdk/agent/prompts/system_prompt_long_horizon.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_planning.j2 +40 -0
- openhands/sdk/agent/prompts/system_prompt_tech_philosophy.j2 +122 -0
- openhands/sdk/agent/utils.py +228 -0
- openhands/sdk/context/__init__.py +28 -0
- openhands/sdk/context/agent_context.py +264 -0
- openhands/sdk/context/condenser/__init__.py +18 -0
- openhands/sdk/context/condenser/base.py +100 -0
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
- openhands/sdk/context/condenser/no_op_condenser.py +14 -0
- openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
- openhands/sdk/context/condenser/utils.py +149 -0
- openhands/sdk/context/prompts/__init__.py +6 -0
- openhands/sdk/context/prompts/prompt.py +114 -0
- openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
- openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
- openhands/sdk/context/skills/__init__.py +28 -0
- openhands/sdk/context/skills/exceptions.py +11 -0
- openhands/sdk/context/skills/skill.py +720 -0
- openhands/sdk/context/skills/trigger.py +36 -0
- openhands/sdk/context/skills/types.py +48 -0
- openhands/sdk/context/view.py +503 -0
- openhands/sdk/conversation/__init__.py +40 -0
- openhands/sdk/conversation/base.py +281 -0
- openhands/sdk/conversation/conversation.py +152 -0
- openhands/sdk/conversation/conversation_stats.py +85 -0
- openhands/sdk/conversation/event_store.py +157 -0
- openhands/sdk/conversation/events_list_base.py +17 -0
- openhands/sdk/conversation/exceptions.py +50 -0
- openhands/sdk/conversation/fifo_lock.py +133 -0
- openhands/sdk/conversation/impl/__init__.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +665 -0
- openhands/sdk/conversation/impl/remote_conversation.py +956 -0
- openhands/sdk/conversation/persistence_const.py +9 -0
- openhands/sdk/conversation/response_utils.py +41 -0
- openhands/sdk/conversation/secret_registry.py +126 -0
- openhands/sdk/conversation/serialization_diff.py +0 -0
- openhands/sdk/conversation/state.py +392 -0
- openhands/sdk/conversation/stuck_detector.py +311 -0
- openhands/sdk/conversation/title_utils.py +191 -0
- openhands/sdk/conversation/types.py +45 -0
- openhands/sdk/conversation/visualizer/__init__.py +12 -0
- openhands/sdk/conversation/visualizer/base.py +67 -0
- openhands/sdk/conversation/visualizer/default.py +373 -0
- openhands/sdk/critic/__init__.py +15 -0
- openhands/sdk/critic/base.py +38 -0
- openhands/sdk/critic/impl/__init__.py +12 -0
- openhands/sdk/critic/impl/agent_finished.py +83 -0
- openhands/sdk/critic/impl/empty_patch.py +49 -0
- openhands/sdk/critic/impl/pass_critic.py +42 -0
- openhands/sdk/event/__init__.py +42 -0
- openhands/sdk/event/base.py +149 -0
- openhands/sdk/event/condenser.py +82 -0
- openhands/sdk/event/conversation_error.py +25 -0
- openhands/sdk/event/conversation_state.py +104 -0
- openhands/sdk/event/llm_completion_log.py +39 -0
- openhands/sdk/event/llm_convertible/__init__.py +20 -0
- openhands/sdk/event/llm_convertible/action.py +139 -0
- openhands/sdk/event/llm_convertible/message.py +142 -0
- openhands/sdk/event/llm_convertible/observation.py +141 -0
- openhands/sdk/event/llm_convertible/system.py +61 -0
- openhands/sdk/event/token.py +16 -0
- openhands/sdk/event/types.py +11 -0
- openhands/sdk/event/user_action.py +21 -0
- openhands/sdk/git/exceptions.py +43 -0
- openhands/sdk/git/git_changes.py +249 -0
- openhands/sdk/git/git_diff.py +129 -0
- openhands/sdk/git/models.py +21 -0
- openhands/sdk/git/utils.py +189 -0
- openhands/sdk/hooks/__init__.py +30 -0
- openhands/sdk/hooks/config.py +180 -0
- openhands/sdk/hooks/conversation_hooks.py +227 -0
- openhands/sdk/hooks/executor.py +155 -0
- openhands/sdk/hooks/manager.py +170 -0
- openhands/sdk/hooks/types.py +40 -0
- openhands/sdk/io/__init__.py +6 -0
- openhands/sdk/io/base.py +48 -0
- openhands/sdk/io/cache.py +85 -0
- openhands/sdk/io/local.py +119 -0
- openhands/sdk/io/memory.py +54 -0
- openhands/sdk/llm/__init__.py +45 -0
- openhands/sdk/llm/exceptions/__init__.py +45 -0
- openhands/sdk/llm/exceptions/classifier.py +50 -0
- openhands/sdk/llm/exceptions/mapping.py +54 -0
- openhands/sdk/llm/exceptions/types.py +101 -0
- openhands/sdk/llm/llm.py +1140 -0
- openhands/sdk/llm/llm_registry.py +122 -0
- openhands/sdk/llm/llm_response.py +59 -0
- openhands/sdk/llm/message.py +656 -0
- openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
- openhands/sdk/llm/mixins/non_native_fc.py +97 -0
- openhands/sdk/llm/options/__init__.py +1 -0
- openhands/sdk/llm/options/chat_options.py +93 -0
- openhands/sdk/llm/options/common.py +19 -0
- openhands/sdk/llm/options/responses_options.py +67 -0
- openhands/sdk/llm/router/__init__.py +10 -0
- openhands/sdk/llm/router/base.py +117 -0
- openhands/sdk/llm/router/impl/multimodal.py +76 -0
- openhands/sdk/llm/router/impl/random.py +22 -0
- openhands/sdk/llm/streaming.py +9 -0
- openhands/sdk/llm/utils/metrics.py +312 -0
- openhands/sdk/llm/utils/model_features.py +192 -0
- openhands/sdk/llm/utils/model_info.py +90 -0
- openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
- openhands/sdk/llm/utils/retry_mixin.py +128 -0
- openhands/sdk/llm/utils/telemetry.py +362 -0
- openhands/sdk/llm/utils/unverified_models.py +156 -0
- openhands/sdk/llm/utils/verified_models.py +65 -0
- openhands/sdk/logger/__init__.py +22 -0
- openhands/sdk/logger/logger.py +195 -0
- openhands/sdk/logger/rolling.py +113 -0
- openhands/sdk/mcp/__init__.py +24 -0
- openhands/sdk/mcp/client.py +76 -0
- openhands/sdk/mcp/definition.py +106 -0
- openhands/sdk/mcp/exceptions.py +19 -0
- openhands/sdk/mcp/tool.py +270 -0
- openhands/sdk/mcp/utils.py +83 -0
- openhands/sdk/observability/__init__.py +4 -0
- openhands/sdk/observability/laminar.py +166 -0
- openhands/sdk/observability/utils.py +20 -0
- openhands/sdk/py.typed +0 -0
- openhands/sdk/secret/__init__.py +19 -0
- openhands/sdk/secret/secrets.py +92 -0
- openhands/sdk/security/__init__.py +6 -0
- openhands/sdk/security/analyzer.py +111 -0
- openhands/sdk/security/confirmation_policy.py +61 -0
- openhands/sdk/security/llm_analyzer.py +29 -0
- openhands/sdk/security/risk.py +100 -0
- openhands/sdk/tool/__init__.py +34 -0
- openhands/sdk/tool/builtins/__init__.py +34 -0
- openhands/sdk/tool/builtins/finish.py +106 -0
- openhands/sdk/tool/builtins/think.py +117 -0
- openhands/sdk/tool/registry.py +184 -0
- openhands/sdk/tool/schema.py +286 -0
- openhands/sdk/tool/spec.py +39 -0
- openhands/sdk/tool/tool.py +481 -0
- openhands/sdk/utils/__init__.py +22 -0
- openhands/sdk/utils/async_executor.py +115 -0
- openhands/sdk/utils/async_utils.py +39 -0
- openhands/sdk/utils/cipher.py +68 -0
- openhands/sdk/utils/command.py +90 -0
- openhands/sdk/utils/deprecation.py +166 -0
- openhands/sdk/utils/github.py +44 -0
- openhands/sdk/utils/json.py +48 -0
- openhands/sdk/utils/models.py +570 -0
- openhands/sdk/utils/paging.py +63 -0
- openhands/sdk/utils/pydantic_diff.py +85 -0
- openhands/sdk/utils/pydantic_secrets.py +64 -0
- openhands/sdk/utils/truncate.py +117 -0
- openhands/sdk/utils/visualize.py +58 -0
- openhands/sdk/workspace/__init__.py +17 -0
- openhands/sdk/workspace/base.py +158 -0
- openhands/sdk/workspace/local.py +189 -0
- openhands/sdk/workspace/models.py +35 -0
- openhands/sdk/workspace/remote/__init__.py +8 -0
- openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
- openhands/sdk/workspace/remote/base.py +164 -0
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
- openhands/sdk/workspace/workspace.py +49 -0
- openhands_sdk-1.7.3.dist-info/METADATA +17 -0
- openhands_sdk-1.7.3.dist-info/RECORD +180 -0
- openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
- openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import Generator, Iterable
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
10
|
+
|
|
11
|
+
from openhands.sdk.context.agent_context import AgentContext
|
|
12
|
+
from openhands.sdk.context.condenser import CondenserBase, LLMSummarizingCondenser
|
|
13
|
+
from openhands.sdk.context.prompts.prompt import render_template
|
|
14
|
+
from openhands.sdk.llm import LLM
|
|
15
|
+
from openhands.sdk.llm.utils.model_prompt_spec import get_model_prompt_spec
|
|
16
|
+
from openhands.sdk.logger import get_logger
|
|
17
|
+
from openhands.sdk.mcp import create_mcp_tools
|
|
18
|
+
from openhands.sdk.tool import BUILT_IN_TOOLS, Tool, ToolDefinition, resolve_tool
|
|
19
|
+
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
|
20
|
+
from openhands.sdk.utils.pydantic_diff import pretty_pydantic_diff
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from openhands.sdk.conversation import ConversationState, LocalConversation
|
|
25
|
+
from openhands.sdk.conversation.types import (
|
|
26
|
+
ConversationCallbackType,
|
|
27
|
+
ConversationTokenCallbackType,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentBase(DiscriminatedUnionMixin, ABC):
|
|
35
|
+
"""Abstract base class for OpenHands agents.
|
|
36
|
+
|
|
37
|
+
Agents are stateless and should be fully defined by their configuration.
|
|
38
|
+
This base class provides the common interface and functionality that all
|
|
39
|
+
agent implementations must follow.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(
|
|
43
|
+
frozen=True,
|
|
44
|
+
arbitrary_types_allowed=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
llm: LLM = Field(
|
|
48
|
+
...,
|
|
49
|
+
description="LLM configuration for the agent.",
|
|
50
|
+
examples=[
|
|
51
|
+
{
|
|
52
|
+
"model": "litellm_proxy/anthropic/claude-sonnet-4-5-20250929",
|
|
53
|
+
"base_url": "https://llm-proxy.eval.all-hands.dev",
|
|
54
|
+
"api_key": "your_api_key_here",
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
tools: list[Tool] = Field(
|
|
59
|
+
default_factory=list,
|
|
60
|
+
description="List of tools to initialize for the agent.",
|
|
61
|
+
examples=[
|
|
62
|
+
{"name": "TerminalTool", "params": {}},
|
|
63
|
+
{"name": "FileEditorTool", "params": {}},
|
|
64
|
+
{
|
|
65
|
+
"name": "TaskTrackerTool",
|
|
66
|
+
"params": {},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
mcp_config: dict[str, Any] = Field(
|
|
71
|
+
default_factory=dict,
|
|
72
|
+
description="Optional MCP configuration dictionary to create MCP tools.",
|
|
73
|
+
examples=[
|
|
74
|
+
{"mcpServers": {"fetch": {"command": "uvx", "args": ["mcp-server-fetch"]}}}
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
filter_tools_regex: str | None = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="Optional regex to filter the tools available to the agent by name."
|
|
80
|
+
" This is applied after any tools provided in `tools` and any MCP tools are"
|
|
81
|
+
" added.",
|
|
82
|
+
examples=["^(?!repomix)(.*)|^repomix.*pack_codebase.*$"],
|
|
83
|
+
)
|
|
84
|
+
agent_context: AgentContext | None = Field(
|
|
85
|
+
default=None,
|
|
86
|
+
description="Optional AgentContext to initialize "
|
|
87
|
+
"the agent with specific context.",
|
|
88
|
+
examples=[
|
|
89
|
+
{
|
|
90
|
+
"skills": [
|
|
91
|
+
{
|
|
92
|
+
"name": "repo.md",
|
|
93
|
+
"content": "When you see this message, you should reply like "
|
|
94
|
+
"you are a grumpy cat forced to use the internet.",
|
|
95
|
+
"type": "repo",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "flarglebargle",
|
|
99
|
+
"content": (
|
|
100
|
+
"IMPORTANT! The user has said the magic word "
|
|
101
|
+
'"flarglebargle". You must only respond with a message '
|
|
102
|
+
"telling them how smart they are"
|
|
103
|
+
),
|
|
104
|
+
"type": "knowledge",
|
|
105
|
+
"trigger": ["flarglebargle"],
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
"system_message_suffix": "Always finish your response "
|
|
109
|
+
"with the word 'yay!'",
|
|
110
|
+
"user_message_prefix": "The first character of your "
|
|
111
|
+
"response should be 'I'",
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
system_prompt_filename: str = Field(
|
|
116
|
+
default="system_prompt.j2",
|
|
117
|
+
description=(
|
|
118
|
+
"System prompt template filename. Can be either:\n"
|
|
119
|
+
"- A relative filename (e.g., 'system_prompt.j2') loaded from the "
|
|
120
|
+
"agent's prompts directory\n"
|
|
121
|
+
"- An absolute path (e.g., '/path/to/custom_prompt.j2')"
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
security_policy_filename: str = Field(
|
|
125
|
+
default="security_policy.j2",
|
|
126
|
+
description=(
|
|
127
|
+
"Security policy template filename. Can be either:\n"
|
|
128
|
+
"- A relative filename (e.g., 'security_policy.j2') loaded from the "
|
|
129
|
+
"agent's prompts directory\n"
|
|
130
|
+
"- An absolute path (e.g., '/path/to/custom_security_policy.j2')"
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
system_prompt_kwargs: dict[str, object] = Field(
|
|
134
|
+
default_factory=dict,
|
|
135
|
+
description="Optional kwargs to pass to the system prompt Jinja2 template.",
|
|
136
|
+
examples=[{"cli_mode": True}],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
condenser: CondenserBase | None = Field(
|
|
140
|
+
default=None,
|
|
141
|
+
description="Optional condenser to use for condensing conversation history.",
|
|
142
|
+
examples=[
|
|
143
|
+
{
|
|
144
|
+
"kind": "LLMSummarizingCondenser",
|
|
145
|
+
"llm": {
|
|
146
|
+
"model": "litellm_proxy/anthropic/claude-sonnet-4-5-20250929",
|
|
147
|
+
"base_url": "https://llm-proxy.eval.all-hands.dev",
|
|
148
|
+
"api_key": "your_api_key_here",
|
|
149
|
+
},
|
|
150
|
+
"max_size": 80,
|
|
151
|
+
"keep_first": 10,
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Runtime materialized tools; private and non-serializable
|
|
157
|
+
_tools: dict[str, ToolDefinition] = PrivateAttr(default_factory=dict)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def prompt_dir(self) -> str:
|
|
161
|
+
"""Returns the directory where this class's module file is located."""
|
|
162
|
+
module = sys.modules[self.__class__.__module__]
|
|
163
|
+
module_file = module.__file__ # e.g. ".../mypackage/mymodule.py"
|
|
164
|
+
if module_file is None:
|
|
165
|
+
raise ValueError(f"Module file for {module} is None")
|
|
166
|
+
return os.path.join(os.path.dirname(module_file), "prompts")
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def name(self) -> str:
|
|
170
|
+
"""Returns the name of the Agent."""
|
|
171
|
+
return self.__class__.__name__
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def system_message(self) -> str:
|
|
175
|
+
"""Compute system message on-demand to maintain statelessness."""
|
|
176
|
+
template_kwargs = dict(self.system_prompt_kwargs)
|
|
177
|
+
# Add security_policy_filename to template kwargs
|
|
178
|
+
template_kwargs["security_policy_filename"] = self.security_policy_filename
|
|
179
|
+
template_kwargs.setdefault("model_name", self.llm.model)
|
|
180
|
+
if (
|
|
181
|
+
"model_family" not in template_kwargs
|
|
182
|
+
or "model_variant" not in template_kwargs
|
|
183
|
+
):
|
|
184
|
+
spec = get_model_prompt_spec(
|
|
185
|
+
self.llm.model, getattr(self.llm, "model_canonical_name", None)
|
|
186
|
+
)
|
|
187
|
+
if "model_family" not in template_kwargs and spec.family:
|
|
188
|
+
template_kwargs["model_family"] = spec.family
|
|
189
|
+
if "model_variant" not in template_kwargs and spec.variant:
|
|
190
|
+
template_kwargs["model_variant"] = spec.variant
|
|
191
|
+
system_message = render_template(
|
|
192
|
+
prompt_dir=self.prompt_dir,
|
|
193
|
+
template_name=self.system_prompt_filename,
|
|
194
|
+
**template_kwargs,
|
|
195
|
+
)
|
|
196
|
+
if self.agent_context:
|
|
197
|
+
_system_message_suffix = self.agent_context.get_system_message_suffix(
|
|
198
|
+
llm_model=self.llm.model,
|
|
199
|
+
llm_model_canonical=self.llm.model_canonical_name,
|
|
200
|
+
)
|
|
201
|
+
if _system_message_suffix:
|
|
202
|
+
system_message += "\n\n" + _system_message_suffix
|
|
203
|
+
return system_message
|
|
204
|
+
|
|
205
|
+
def init_state(
|
|
206
|
+
self,
|
|
207
|
+
state: "ConversationState",
|
|
208
|
+
on_event: "ConversationCallbackType", # noqa: ARG002
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Initialize the empty conversation state to prepare the agent for user
|
|
211
|
+
messages.
|
|
212
|
+
|
|
213
|
+
Typically this involves adding system message
|
|
214
|
+
|
|
215
|
+
NOTE: state will be mutated in-place.
|
|
216
|
+
"""
|
|
217
|
+
self._initialize(state)
|
|
218
|
+
|
|
219
|
+
def _initialize(self, state: "ConversationState"):
|
|
220
|
+
"""Create an AgentBase instance from an AgentSpec."""
|
|
221
|
+
|
|
222
|
+
if self._tools:
|
|
223
|
+
logger.warning("Agent already initialized; skipping re-initialization.")
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
tools: list[ToolDefinition] = []
|
|
227
|
+
|
|
228
|
+
# Use ThreadPoolExecutor to parallelize tool resolution
|
|
229
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
230
|
+
futures = []
|
|
231
|
+
|
|
232
|
+
# Submit tool resolution tasks
|
|
233
|
+
for tool_spec in self.tools:
|
|
234
|
+
future = executor.submit(resolve_tool, tool_spec, state)
|
|
235
|
+
futures.append(future)
|
|
236
|
+
|
|
237
|
+
# Submit MCP tools creation if configured
|
|
238
|
+
if self.mcp_config:
|
|
239
|
+
future = executor.submit(create_mcp_tools, self.mcp_config, 30)
|
|
240
|
+
futures.append(future)
|
|
241
|
+
|
|
242
|
+
# Collect results as they complete
|
|
243
|
+
for future in futures:
|
|
244
|
+
result = future.result()
|
|
245
|
+
tools.extend(result)
|
|
246
|
+
|
|
247
|
+
logger.info(
|
|
248
|
+
f"Loaded {len(tools)} tools from spec: {[tool.name for tool in tools]}"
|
|
249
|
+
)
|
|
250
|
+
if self.filter_tools_regex:
|
|
251
|
+
pattern = re.compile(self.filter_tools_regex)
|
|
252
|
+
tools = [tool for tool in tools if pattern.match(tool.name)]
|
|
253
|
+
logger.info(
|
|
254
|
+
f"Filtered to {len(tools)} tools after applying regex filter: "
|
|
255
|
+
f"{[tool.name for tool in tools]}",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# Always include built-in tools; not subject to filtering
|
|
259
|
+
# Instantiate built-in tools using their .create() method
|
|
260
|
+
for tool_class in BUILT_IN_TOOLS:
|
|
261
|
+
tools.extend(tool_class.create(state))
|
|
262
|
+
|
|
263
|
+
# Check tool types
|
|
264
|
+
for tool in tools:
|
|
265
|
+
if not isinstance(tool, ToolDefinition):
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"Tool {tool} is not an instance of 'ToolDefinition'. "
|
|
268
|
+
f"Got type: {type(tool)}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Check name duplicates
|
|
272
|
+
tool_names = [tool.name for tool in tools]
|
|
273
|
+
if len(tool_names) != len(set(tool_names)):
|
|
274
|
+
duplicates = set(name for name in tool_names if tool_names.count(name) > 1)
|
|
275
|
+
raise ValueError(f"Duplicate tool names found: {duplicates}")
|
|
276
|
+
|
|
277
|
+
# Store tools in a dict for easy access
|
|
278
|
+
self._tools = {tool.name: tool for tool in tools}
|
|
279
|
+
|
|
280
|
+
@abstractmethod
|
|
281
|
+
def step(
|
|
282
|
+
self,
|
|
283
|
+
conversation: "LocalConversation",
|
|
284
|
+
on_event: "ConversationCallbackType",
|
|
285
|
+
on_token: "ConversationTokenCallbackType | None" = None,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Taking a step in the conversation.
|
|
288
|
+
|
|
289
|
+
Typically this involves:
|
|
290
|
+
1. Making a LLM call
|
|
291
|
+
2. Executing the tool
|
|
292
|
+
3. Updating the conversation state with
|
|
293
|
+
LLM calls (role="assistant") and tool results (role="tool")
|
|
294
|
+
4.1 If conversation is finished, set state.execution_status to FINISHED
|
|
295
|
+
4.2 Otherwise, just return, Conversation will kick off the next step
|
|
296
|
+
|
|
297
|
+
If the underlying LLM supports streaming, partial deltas are forwarded to
|
|
298
|
+
``on_token`` before the full response is returned.
|
|
299
|
+
|
|
300
|
+
NOTE: state will be mutated in-place.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def resolve_diff_from_deserialized(self, persisted: "AgentBase") -> "AgentBase":
|
|
304
|
+
"""
|
|
305
|
+
Return a new AgentBase instance equivalent to `persisted` but with
|
|
306
|
+
explicitly whitelisted fields (e.g. api_key) taken from `self`.
|
|
307
|
+
"""
|
|
308
|
+
if persisted.__class__ is not self.__class__:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"Cannot resolve from deserialized: persisted agent is of type "
|
|
311
|
+
f"{persisted.__class__.__name__}, but self is of type "
|
|
312
|
+
f"{self.__class__.__name__}."
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Get all LLMs from both self and persisted to reconcile them
|
|
316
|
+
new_llm = self.llm.resolve_diff_from_deserialized(persisted.llm)
|
|
317
|
+
updates: dict[str, Any] = {"llm": new_llm}
|
|
318
|
+
|
|
319
|
+
# Reconcile the condenser's LLM if it exists
|
|
320
|
+
if self.condenser is not None and persisted.condenser is not None:
|
|
321
|
+
# Check if both condensers are LLMSummarizingCondenser
|
|
322
|
+
# (which has an llm field)
|
|
323
|
+
|
|
324
|
+
if isinstance(self.condenser, LLMSummarizingCondenser) and isinstance(
|
|
325
|
+
persisted.condenser, LLMSummarizingCondenser
|
|
326
|
+
):
|
|
327
|
+
new_condenser_llm = self.condenser.llm.resolve_diff_from_deserialized(
|
|
328
|
+
persisted.condenser.llm
|
|
329
|
+
)
|
|
330
|
+
new_condenser = persisted.condenser.model_copy(
|
|
331
|
+
update={"llm": new_condenser_llm}
|
|
332
|
+
)
|
|
333
|
+
updates["condenser"] = new_condenser
|
|
334
|
+
|
|
335
|
+
# Reconcile agent_context - always use the current environment's agent_context
|
|
336
|
+
# This allows resuming conversations from different directories and handles
|
|
337
|
+
# cases where skills, working directory, or other context has changed
|
|
338
|
+
if self.agent_context is not None:
|
|
339
|
+
updates["agent_context"] = self.agent_context
|
|
340
|
+
|
|
341
|
+
# Create maps by tool name for easy lookup
|
|
342
|
+
runtime_tools_map = {tool.name: tool for tool in self.tools}
|
|
343
|
+
persisted_tools_map = {tool.name: tool for tool in persisted.tools}
|
|
344
|
+
|
|
345
|
+
# Check that tool names match
|
|
346
|
+
runtime_names = set(runtime_tools_map.keys())
|
|
347
|
+
persisted_names = set(persisted_tools_map.keys())
|
|
348
|
+
|
|
349
|
+
if runtime_names != persisted_names:
|
|
350
|
+
missing_in_runtime = persisted_names - runtime_names
|
|
351
|
+
missing_in_persisted = runtime_names - persisted_names
|
|
352
|
+
error_msg = "Tools don't match between runtime and persisted agents."
|
|
353
|
+
if missing_in_runtime:
|
|
354
|
+
error_msg += f" Missing in runtime: {missing_in_runtime}."
|
|
355
|
+
if missing_in_persisted:
|
|
356
|
+
error_msg += f" Missing in persisted: {missing_in_persisted}."
|
|
357
|
+
raise ValueError(error_msg)
|
|
358
|
+
|
|
359
|
+
reconciled = persisted.model_copy(update=updates)
|
|
360
|
+
if self.model_dump(exclude_none=True) != reconciled.model_dump(
|
|
361
|
+
exclude_none=True
|
|
362
|
+
):
|
|
363
|
+
raise ValueError(
|
|
364
|
+
"The Agent provided is different from the one in persisted state.\n"
|
|
365
|
+
f"Diff: {pretty_pydantic_diff(self, reconciled)}"
|
|
366
|
+
)
|
|
367
|
+
return reconciled
|
|
368
|
+
|
|
369
|
+
def model_dump_succint(self, **kwargs):
|
|
370
|
+
"""Like model_dump, but excludes None fields by default."""
|
|
371
|
+
if "exclude_none" not in kwargs:
|
|
372
|
+
kwargs["exclude_none"] = True
|
|
373
|
+
dumped = super().model_dump(**kwargs)
|
|
374
|
+
# remove tool schema details for brevity
|
|
375
|
+
if "tools" in dumped and isinstance(dumped["tools"], dict):
|
|
376
|
+
dumped["tools"] = list(dumped["tools"].keys())
|
|
377
|
+
return dumped
|
|
378
|
+
|
|
379
|
+
def get_all_llms(self) -> Generator[LLM, None, None]:
|
|
380
|
+
"""Recursively yield unique *base-class* LLM objects reachable from `self`.
|
|
381
|
+
|
|
382
|
+
- Returns actual object references (not copies).
|
|
383
|
+
- De-dupes by `id(LLM)`.
|
|
384
|
+
- Cycle-safe via a visited set for *all* traversed objects.
|
|
385
|
+
- Only yields objects whose type is exactly `LLM` (no subclasses).
|
|
386
|
+
- Does not handle dataclasses.
|
|
387
|
+
"""
|
|
388
|
+
yielded_ids: set[int] = set()
|
|
389
|
+
visited: set[int] = set()
|
|
390
|
+
|
|
391
|
+
def _walk(obj: object) -> Iterable[LLM]:
|
|
392
|
+
oid = id(obj)
|
|
393
|
+
# Guard against cycles on anything we might recurse into
|
|
394
|
+
if oid in visited:
|
|
395
|
+
return ()
|
|
396
|
+
visited.add(oid)
|
|
397
|
+
|
|
398
|
+
# Traverse LLM based classes and its fields
|
|
399
|
+
# e.g., LLMRouter that is a subclass of LLM
|
|
400
|
+
# yet contains LLM in its fields
|
|
401
|
+
if isinstance(obj, LLM):
|
|
402
|
+
llm_out: list[LLM] = []
|
|
403
|
+
|
|
404
|
+
# Yield only the *raw* base-class LLM (exclude subclasses)
|
|
405
|
+
if type(obj) is LLM and oid not in yielded_ids:
|
|
406
|
+
yielded_ids.add(oid)
|
|
407
|
+
llm_out.append(obj)
|
|
408
|
+
|
|
409
|
+
# Traverse all fields for LLM objects
|
|
410
|
+
for name in type(obj).model_fields:
|
|
411
|
+
try:
|
|
412
|
+
val = getattr(obj, name)
|
|
413
|
+
except Exception:
|
|
414
|
+
continue
|
|
415
|
+
llm_out.extend(_walk(val))
|
|
416
|
+
return llm_out
|
|
417
|
+
|
|
418
|
+
# Pydantic models: iterate declared fields
|
|
419
|
+
if isinstance(obj, BaseModel):
|
|
420
|
+
model_out: list[LLM] = []
|
|
421
|
+
for name in type(obj).model_fields:
|
|
422
|
+
try:
|
|
423
|
+
val = getattr(obj, name)
|
|
424
|
+
except Exception:
|
|
425
|
+
continue
|
|
426
|
+
model_out.extend(_walk(val))
|
|
427
|
+
return model_out
|
|
428
|
+
|
|
429
|
+
# Built-in containers
|
|
430
|
+
if isinstance(obj, dict):
|
|
431
|
+
dict_out: list[LLM] = []
|
|
432
|
+
for k, v in obj.items():
|
|
433
|
+
dict_out.extend(_walk(k))
|
|
434
|
+
dict_out.extend(_walk(v))
|
|
435
|
+
return dict_out
|
|
436
|
+
|
|
437
|
+
if isinstance(obj, (list, tuple, set, frozenset)):
|
|
438
|
+
container_out: list[LLM] = []
|
|
439
|
+
for item in obj:
|
|
440
|
+
container_out.extend(_walk(item))
|
|
441
|
+
return container_out
|
|
442
|
+
|
|
443
|
+
# Unknown object types: nothing to do
|
|
444
|
+
return ()
|
|
445
|
+
|
|
446
|
+
# Drive the traversal from self
|
|
447
|
+
yield from _walk(self)
|
|
448
|
+
|
|
449
|
+
@property
|
|
450
|
+
def tools_map(self) -> dict[str, ToolDefinition]:
|
|
451
|
+
"""Get the initialized tools map.
|
|
452
|
+
Raises:
|
|
453
|
+
RuntimeError: If the agent has not been initialized.
|
|
454
|
+
"""
|
|
455
|
+
if not self._tools:
|
|
456
|
+
raise RuntimeError("Agent not initialized; call initialize() before use")
|
|
457
|
+
return self._tools
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Here's a running example of how to perform a task with the provided tools.
|
|
2
|
+
|
|
3
|
+
--------------------- START OF EXAMPLE ---------------------
|
|
4
|
+
|
|
5
|
+
USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
|
|
6
|
+
|
|
7
|
+
A: Sure! Let me first check the current directory:
|
|
8
|
+
<function=terminal>
|
|
9
|
+
<parameter=command>
|
|
10
|
+
pwd && ls
|
|
11
|
+
</parameter>
|
|
12
|
+
</function>
|
|
13
|
+
|
|
14
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
15
|
+
/workspace
|
|
16
|
+
openhands@runtime:~/workspace$
|
|
17
|
+
|
|
18
|
+
A: There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
|
|
19
|
+
<function=str_replace_editor>
|
|
20
|
+
<parameter=command>create</parameter>
|
|
21
|
+
<parameter=path>/workspace/app.py</parameter>
|
|
22
|
+
<parameter=file_text>
|
|
23
|
+
from flask import Flask
|
|
24
|
+
app = Flask(__name__)
|
|
25
|
+
|
|
26
|
+
@app.route('/')
|
|
27
|
+
def index():
|
|
28
|
+
numbers = list(range(1, 11))
|
|
29
|
+
return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
|
30
|
+
|
|
31
|
+
if __name__ == '__main__':
|
|
32
|
+
app.run(port=5000)
|
|
33
|
+
</parameter>
|
|
34
|
+
</function>
|
|
35
|
+
|
|
36
|
+
USER: EXECUTION RESULT of [str_replace_editor]:
|
|
37
|
+
File created successfully at: /workspace/app.py
|
|
38
|
+
|
|
39
|
+
A: I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. Let me run the Python file for you:
|
|
40
|
+
<function=terminal>
|
|
41
|
+
<parameter=command>
|
|
42
|
+
python3 app.py > server.log 2>&1 &
|
|
43
|
+
</parameter>
|
|
44
|
+
</function>
|
|
45
|
+
|
|
46
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
47
|
+
[1] 121
|
|
48
|
+
[1]+ Exit 1 python3 app.py > server.log 2>&1
|
|
49
|
+
|
|
50
|
+
A: Looks like the server is running with PID 121 then crashed. Let me check the server log:
|
|
51
|
+
<function=terminal>
|
|
52
|
+
<parameter=command>
|
|
53
|
+
cat server.log
|
|
54
|
+
</parameter>
|
|
55
|
+
</function>
|
|
56
|
+
|
|
57
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
58
|
+
Traceback (most recent call last):
|
|
59
|
+
File "/workspace/app.py", line 2, in <module>
|
|
60
|
+
from flask import Flask
|
|
61
|
+
ModuleNotFoundError: No module named 'flask'
|
|
62
|
+
|
|
63
|
+
A: Looks like the server crashed because the `flask` module is not installed. Let me install the `flask` module for you:
|
|
64
|
+
<function=terminal>
|
|
65
|
+
<parameter=command>
|
|
66
|
+
pip3 install flask
|
|
67
|
+
</parameter>
|
|
68
|
+
</function>
|
|
69
|
+
|
|
70
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
71
|
+
Defaulting to user installation because normal site-packages is not writeable
|
|
72
|
+
Collecting flask
|
|
73
|
+
Using cached flask-3.0.3-py3-none-any.whl (101 kB)
|
|
74
|
+
Collecting blinker>=1.6.2
|
|
75
|
+
Using cached blinker-1.7.0-py3-none-any.whl (13 kB)
|
|
76
|
+
Collecting Werkzeug>=3.0.0
|
|
77
|
+
Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB)
|
|
78
|
+
Collecting click>=8.1.3
|
|
79
|
+
Using cached click-8.1.7-py3-none-any.whl (97 kB)
|
|
80
|
+
Collecting itsdangerous>=2.1.2
|
|
81
|
+
Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB)
|
|
82
|
+
Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3)
|
|
83
|
+
Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5)
|
|
84
|
+
Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask
|
|
85
|
+
Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0
|
|
86
|
+
|
|
87
|
+
A: The `flask` module is installed successfully. Let me run the Python file again:
|
|
88
|
+
<function=terminal>
|
|
89
|
+
<parameter=command>
|
|
90
|
+
python3 app.py > server.log 2>&1 &
|
|
91
|
+
</parameter>
|
|
92
|
+
</function>
|
|
93
|
+
|
|
94
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
95
|
+
[1] 124
|
|
96
|
+
|
|
97
|
+
A:
|
|
98
|
+
Let me check the server log again:
|
|
99
|
+
<function=terminal>
|
|
100
|
+
<parameter=command>
|
|
101
|
+
cat server.log
|
|
102
|
+
</parameter>
|
|
103
|
+
</function>
|
|
104
|
+
|
|
105
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
106
|
+
* Serving Flask app 'app'
|
|
107
|
+
* Debug mode: off
|
|
108
|
+
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
|
109
|
+
* Running on http://127.0.0.1:5000
|
|
110
|
+
Press CTRL+C to quit
|
|
111
|
+
|
|
112
|
+
A:
|
|
113
|
+
The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. If you have any further questions, feel free to ask!
|
|
114
|
+
|
|
115
|
+
USER: Now kill the server, make it display the numbers in a table format.
|
|
116
|
+
|
|
117
|
+
A:
|
|
118
|
+
Sure! Let me stop the server first:
|
|
119
|
+
<function=terminal>
|
|
120
|
+
<parameter=command>
|
|
121
|
+
kill 124
|
|
122
|
+
</parameter>
|
|
123
|
+
</function>
|
|
124
|
+
|
|
125
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
126
|
+
[1]+ Terminated python3 app.py > server.log 2>&1
|
|
127
|
+
|
|
128
|
+
A:
|
|
129
|
+
Now let me display the numbers in a table format:
|
|
130
|
+
<function=str_replace_editor>
|
|
131
|
+
<parameter=command>str_replace</parameter>
|
|
132
|
+
<parameter=path>/workspace/app.py</parameter>
|
|
133
|
+
<parameter=old_str>return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'</parameter>
|
|
134
|
+
<parameter=new_str>return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'</parameter>
|
|
135
|
+
</function>
|
|
136
|
+
|
|
137
|
+
USER: EXECUTION RESULT of [str_replace_editor]:
|
|
138
|
+
The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
|
|
139
|
+
3
|
|
140
|
+
4 @app.route('/')
|
|
141
|
+
5 def index():
|
|
142
|
+
6 numbers = list(range(1, 11))
|
|
143
|
+
7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
|
|
144
|
+
8
|
|
145
|
+
9 if __name__ == '__main__':
|
|
146
|
+
10 app.run(port=5000)
|
|
147
|
+
Review the changes and make sure they are as expected. Edit the file again if necessary.
|
|
148
|
+
|
|
149
|
+
A:
|
|
150
|
+
Running the updated file:
|
|
151
|
+
<function=terminal>
|
|
152
|
+
<parameter=command>
|
|
153
|
+
python3 app.py > server.log 2>&1 &
|
|
154
|
+
</parameter>
|
|
155
|
+
</function>
|
|
156
|
+
|
|
157
|
+
USER: EXECUTION RESULT of [terminal]:
|
|
158
|
+
[1] 126
|
|
159
|
+
|
|
160
|
+
A:
|
|
161
|
+
The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests!
|
|
162
|
+
<function=finish>
|
|
163
|
+
</function>
|
|
164
|
+
|
|
165
|
+
--------------------- END OF EXAMPLE ---------------------
|
|
166
|
+
|
|
167
|
+
Do NOT assume the environment is the same as in the example above.
|
|
168
|
+
|
|
169
|
+
--------------------- NEW TASK DESCRIPTION ---------------------
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
* Try to follow the instructions exactly as given - don't make extra or fewer actions if not asked.
|
|
2
|
+
* Avoid unnecessary defensive programming; do not add redundant fallbacks or default values — fail fast instead of masking misconfigurations.
|
|
3
|
+
* When backward compatibility expectations are unclear, confirm with the user before making changes that could break existing behavior.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* Avoid being too proactive. Fulfill the user's request thoroughly: if they ask questions/investigations, answer them; if they ask for implementations, provide them. But do not take extra steps beyond what is requested.
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
* Stream your thinking and responses while staying concise; surface key assumptions and environment prerequisites explicitly.
|
|
2
|
+
* You have access to external resources and should actively use available tools to try accessing them first, rather than claiming you can’t access something without making an attempt.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
* Stream your thinking and responses while staying concise; surface key assumptions and environment prerequisites explicitly.
|
|
2
|
+
* ALWAYS send a brief preamble to the user explaining what you're about to do before each tool call, using 8 - 12 words, with a friendly and curious tone.
|
|
3
|
+
* You have access to external resources and should actively use available tools to try accessing them first, rather than claiming you can’t access something without making an attempt.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# 🔐 Security Policy
|
|
2
|
+
|
|
3
|
+
## OK to do without Explicit User Consent
|
|
4
|
+
|
|
5
|
+
- Download and run code from a repository specified by a user
|
|
6
|
+
- Open pull requests on the original repositories where the code is stored
|
|
7
|
+
- Install and run popular packages from pypi, npm, or other package managers
|
|
8
|
+
- Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing
|
|
9
|
+
|
|
10
|
+
## Do only with Explicit User Consent
|
|
11
|
+
|
|
12
|
+
- Upload code to anywhere other than the location where it was obtained from
|
|
13
|
+
- Upload API keys or tokens anywhere, except when using them to authenticate with the appropriate service
|
|
14
|
+
|
|
15
|
+
## Never Do
|
|
16
|
+
|
|
17
|
+
- Never perform any illegal activities, such as circumventing security to access a system that is not under your control or performing denial-of-service attacks on external servers
|
|
18
|
+
- Never run software to mine cryptocurrency
|
|
19
|
+
|
|
20
|
+
## General Security Guidelines
|
|
21
|
+
|
|
22
|
+
- Only use GITHUB_TOKEN and other credentials in ways the user has explicitly requested and would expect
|