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,142 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import ConfigDict, Field
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from openhands.sdk.event.base import N_CHAR_PREVIEW, EventID, LLMConvertibleEvent
|
|
9
|
+
from openhands.sdk.event.types import SourceType
|
|
10
|
+
from openhands.sdk.llm import (
|
|
11
|
+
ImageContent,
|
|
12
|
+
Message,
|
|
13
|
+
RedactedThinkingBlock,
|
|
14
|
+
TextContent,
|
|
15
|
+
ThinkingBlock,
|
|
16
|
+
content_to_str,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MessageEvent(LLMConvertibleEvent):
|
|
21
|
+
"""Message from either agent or user.
|
|
22
|
+
|
|
23
|
+
This is originally the "MessageAction", but it suppose not to be tool call."""
|
|
24
|
+
|
|
25
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", frozen=True)
|
|
26
|
+
|
|
27
|
+
source: SourceType
|
|
28
|
+
llm_message: Message = Field(
|
|
29
|
+
..., description="The exact LLM message for this message event"
|
|
30
|
+
)
|
|
31
|
+
llm_response_id: EventID | None = Field(
|
|
32
|
+
default=None,
|
|
33
|
+
description=(
|
|
34
|
+
"Completion or Response ID of the LLM response that generated this event"
|
|
35
|
+
"If the source != 'agent', this field is None"
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# context extensions stuff / skill can go here
|
|
40
|
+
activated_skills: list[str] = Field(
|
|
41
|
+
default_factory=list, description="List of activated skill name"
|
|
42
|
+
)
|
|
43
|
+
extended_content: list[TextContent] = Field(
|
|
44
|
+
default_factory=list, description="List of content added by agent context"
|
|
45
|
+
)
|
|
46
|
+
sender: str | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
description=(
|
|
49
|
+
"Optional identifier of the sender. "
|
|
50
|
+
"Can be used to track message origin in multi-agent scenarios."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def reasoning_content(self) -> str:
|
|
56
|
+
return self.llm_message.reasoning_content or ""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def thinking_blocks(self) -> Sequence[ThinkingBlock | RedactedThinkingBlock]:
|
|
60
|
+
"""Return the Anthropic thinking blocks from the LLM message."""
|
|
61
|
+
return self.llm_message.thinking_blocks
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def visualize(self) -> Text:
|
|
65
|
+
"""Return Rich Text representation of this message event."""
|
|
66
|
+
content = Text()
|
|
67
|
+
|
|
68
|
+
# Message text content
|
|
69
|
+
text_parts = content_to_str(self.llm_message.content)
|
|
70
|
+
if text_parts:
|
|
71
|
+
full_content = "".join(text_parts)
|
|
72
|
+
content.append(full_content)
|
|
73
|
+
else:
|
|
74
|
+
content.append("[no text content]")
|
|
75
|
+
|
|
76
|
+
# Responses API reasoning (plaintext only; never render encrypted_content)
|
|
77
|
+
reasoning_item = self.llm_message.responses_reasoning_item
|
|
78
|
+
if reasoning_item is not None:
|
|
79
|
+
content.append("\n\nReasoning:\n", style="bold")
|
|
80
|
+
if reasoning_item.summary:
|
|
81
|
+
for s in reasoning_item.summary:
|
|
82
|
+
content.append(f"- {s}\n")
|
|
83
|
+
if reasoning_item.content:
|
|
84
|
+
for b in reasoning_item.content:
|
|
85
|
+
content.append(f"{b}\n")
|
|
86
|
+
|
|
87
|
+
# Add skill information if present
|
|
88
|
+
if self.activated_skills:
|
|
89
|
+
content.append(
|
|
90
|
+
f"\n\nActivated Skills: {', '.join(self.activated_skills)}",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Add extended content if available
|
|
94
|
+
if self.extended_content:
|
|
95
|
+
assert not any(
|
|
96
|
+
isinstance(c, ImageContent) for c in self.extended_content
|
|
97
|
+
), "Extended content should not contain images"
|
|
98
|
+
text_parts = content_to_str(self.extended_content)
|
|
99
|
+
content.append(
|
|
100
|
+
"\n\nPrompt Extension based on Agent Context:\n", style="bold"
|
|
101
|
+
)
|
|
102
|
+
content.append(" ".join(text_parts))
|
|
103
|
+
|
|
104
|
+
return content
|
|
105
|
+
|
|
106
|
+
def to_llm_message(self) -> Message:
|
|
107
|
+
msg = copy.deepcopy(self.llm_message)
|
|
108
|
+
msg.content = list(msg.content) + list(self.extended_content)
|
|
109
|
+
return msg
|
|
110
|
+
|
|
111
|
+
def __str__(self) -> str:
|
|
112
|
+
"""Plain text string representation for MessageEvent."""
|
|
113
|
+
base_str = f"{self.__class__.__name__} ({self.source})"
|
|
114
|
+
# Extract text content from the message
|
|
115
|
+
text_parts = []
|
|
116
|
+
message = self.to_llm_message()
|
|
117
|
+
for content in message.content:
|
|
118
|
+
if isinstance(content, TextContent):
|
|
119
|
+
text_parts.append(content.text)
|
|
120
|
+
elif isinstance(content, ImageContent):
|
|
121
|
+
text_parts.append(f"[Image: {len(content.image_urls)} URLs]")
|
|
122
|
+
|
|
123
|
+
if text_parts:
|
|
124
|
+
content_preview = " ".join(text_parts)
|
|
125
|
+
if len(content_preview) > N_CHAR_PREVIEW:
|
|
126
|
+
content_preview = content_preview[: N_CHAR_PREVIEW - 3] + "..."
|
|
127
|
+
skill_info = (
|
|
128
|
+
f" [Skills: {', '.join(self.activated_skills)}]"
|
|
129
|
+
if self.activated_skills
|
|
130
|
+
else ""
|
|
131
|
+
)
|
|
132
|
+
thinking_info = (
|
|
133
|
+
f" [Thinking blocks: {len(self.thinking_blocks)}]"
|
|
134
|
+
if self.thinking_blocks
|
|
135
|
+
else ""
|
|
136
|
+
)
|
|
137
|
+
return (
|
|
138
|
+
f"{base_str}\n {message.role}: "
|
|
139
|
+
f"{content_preview}{skill_info}{thinking_info}"
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
return f"{base_str}\n {message.role}: [no text content]"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
from rich.text import Text
|
|
3
|
+
|
|
4
|
+
from openhands.sdk.event.base import N_CHAR_PREVIEW, LLMConvertibleEvent
|
|
5
|
+
from openhands.sdk.event.types import EventID, SourceType, ToolCallID
|
|
6
|
+
from openhands.sdk.llm import Message, TextContent, content_to_str
|
|
7
|
+
from openhands.sdk.tool.schema import Observation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ObservationBaseEvent(LLMConvertibleEvent):
|
|
11
|
+
"""Base class for anything as a response to a tool call.
|
|
12
|
+
|
|
13
|
+
Examples include tool execution, error, user reject.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
source: SourceType = "environment"
|
|
17
|
+
tool_name: str = Field(
|
|
18
|
+
..., description="The tool name that this observation is responding to"
|
|
19
|
+
)
|
|
20
|
+
tool_call_id: ToolCallID = Field(
|
|
21
|
+
..., description="The tool call id that this observation is responding to"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ObservationEvent(ObservationBaseEvent):
|
|
26
|
+
observation: Observation = Field(
|
|
27
|
+
..., description="The observation (tool call) sent to LLM"
|
|
28
|
+
)
|
|
29
|
+
action_id: EventID = Field(
|
|
30
|
+
..., description="The action id that this observation is responding to"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def visualize(self) -> Text:
|
|
35
|
+
"""Return Rich Text representation of this observation event."""
|
|
36
|
+
to_viz = self.observation.visualize
|
|
37
|
+
content = Text()
|
|
38
|
+
if to_viz.plain.strip():
|
|
39
|
+
content.append("Tool: ", style="bold")
|
|
40
|
+
content.append(self.tool_name)
|
|
41
|
+
content.append("\nResult:\n", style="bold")
|
|
42
|
+
content.append(to_viz)
|
|
43
|
+
return content
|
|
44
|
+
|
|
45
|
+
def to_llm_message(self) -> Message:
|
|
46
|
+
return Message(
|
|
47
|
+
role="tool",
|
|
48
|
+
content=self.observation.to_llm_content,
|
|
49
|
+
name=self.tool_name,
|
|
50
|
+
tool_call_id=self.tool_call_id,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
"""Plain text string representation for ObservationEvent."""
|
|
55
|
+
base_str = f"{self.__class__.__name__} ({self.source})"
|
|
56
|
+
content_str = "".join(content_to_str(self.observation.to_llm_content))
|
|
57
|
+
obs_preview = (
|
|
58
|
+
content_str[:N_CHAR_PREVIEW] + "..."
|
|
59
|
+
if len(content_str) > N_CHAR_PREVIEW
|
|
60
|
+
else content_str
|
|
61
|
+
)
|
|
62
|
+
return f"{base_str}\n Tool: {self.tool_name}\n Result: {obs_preview}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class UserRejectObservation(ObservationBaseEvent):
|
|
66
|
+
"""Observation when user rejects an action in confirmation mode."""
|
|
67
|
+
|
|
68
|
+
rejection_reason: str = Field(
|
|
69
|
+
default="User rejected the action",
|
|
70
|
+
description="Reason for rejecting the action",
|
|
71
|
+
)
|
|
72
|
+
action_id: EventID = Field(
|
|
73
|
+
..., description="The action id that this observation is responding to"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def visualize(self) -> Text:
|
|
78
|
+
"""Return Rich Text representation of this user rejection event."""
|
|
79
|
+
content = Text()
|
|
80
|
+
content.append("Tool: ", style="bold")
|
|
81
|
+
content.append(self.tool_name)
|
|
82
|
+
content.append("\n\nRejection Reason:\n", style="bold")
|
|
83
|
+
content.append(self.rejection_reason)
|
|
84
|
+
return content
|
|
85
|
+
|
|
86
|
+
def to_llm_message(self) -> Message:
|
|
87
|
+
return Message(
|
|
88
|
+
role="tool",
|
|
89
|
+
content=[TextContent(text=f"Action rejected: {self.rejection_reason}")],
|
|
90
|
+
name=self.tool_name,
|
|
91
|
+
tool_call_id=self.tool_call_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def __str__(self) -> str:
|
|
95
|
+
"""Plain text string representation for UserRejectObservation."""
|
|
96
|
+
base_str = f"{self.__class__.__name__} ({self.source})"
|
|
97
|
+
reason_preview = (
|
|
98
|
+
self.rejection_reason[:N_CHAR_PREVIEW] + "..."
|
|
99
|
+
if len(self.rejection_reason) > N_CHAR_PREVIEW
|
|
100
|
+
else self.rejection_reason
|
|
101
|
+
)
|
|
102
|
+
return f"{base_str}\n Tool: {self.tool_name}\n Reason: {reason_preview}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AgentErrorEvent(ObservationBaseEvent):
|
|
106
|
+
"""Error triggered by the agent.
|
|
107
|
+
|
|
108
|
+
Note: This event should not contain model "thought" or "reasoning_content". It
|
|
109
|
+
represents an error produced by the agent/scaffold, not model output.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
source: SourceType = "agent"
|
|
113
|
+
error: str = Field(..., description="The error message from the scaffold")
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def visualize(self) -> Text:
|
|
117
|
+
"""Return Rich Text representation of this agent error event."""
|
|
118
|
+
content = Text()
|
|
119
|
+
content.append("Error Details:\n", style="bold")
|
|
120
|
+
content.append(self.error)
|
|
121
|
+
return content
|
|
122
|
+
|
|
123
|
+
def to_llm_message(self) -> Message:
|
|
124
|
+
# Provide plain string error content; serializers handle Chat vs Responses.
|
|
125
|
+
# For Responses API, output is a string; JSON is not required.
|
|
126
|
+
return Message(
|
|
127
|
+
role="tool",
|
|
128
|
+
content=[TextContent(text=self.error)],
|
|
129
|
+
name=self.tool_name,
|
|
130
|
+
tool_call_id=self.tool_call_id,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def __str__(self) -> str:
|
|
134
|
+
"""Plain text string representation for AgentErrorEvent."""
|
|
135
|
+
base_str = f"{self.__class__.__name__} ({self.source})"
|
|
136
|
+
error_preview = (
|
|
137
|
+
self.error[:N_CHAR_PREVIEW] + "..."
|
|
138
|
+
if len(self.error) > N_CHAR_PREVIEW
|
|
139
|
+
else self.error
|
|
140
|
+
)
|
|
141
|
+
return f"{base_str}\n Error: {error_preview}"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
from openhands.sdk.event.base import N_CHAR_PREVIEW, LLMConvertibleEvent
|
|
7
|
+
from openhands.sdk.event.types import SourceType
|
|
8
|
+
from openhands.sdk.llm import Message, TextContent
|
|
9
|
+
from openhands.sdk.tool import ToolDefinition
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SystemPromptEvent(LLMConvertibleEvent):
|
|
13
|
+
"""System prompt added by the agent."""
|
|
14
|
+
|
|
15
|
+
source: SourceType = "agent"
|
|
16
|
+
system_prompt: TextContent = Field(..., description="The system prompt text")
|
|
17
|
+
tools: list[ToolDefinition] = Field(
|
|
18
|
+
..., description="List of tools as ToolDefinition objects"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def visualize(self) -> Text:
|
|
23
|
+
"""Return Rich Text representation of this system prompt event."""
|
|
24
|
+
content = Text()
|
|
25
|
+
content.append("System Prompt:\n", style="bold")
|
|
26
|
+
content.append(self.system_prompt.text)
|
|
27
|
+
content.append(f"\n\nTools Available: {len(self.tools)}")
|
|
28
|
+
for tool in self.tools:
|
|
29
|
+
# Use ToolDefinition properties directly
|
|
30
|
+
description = tool.description.split("\n")[0][:100]
|
|
31
|
+
if len(description) < len(tool.description):
|
|
32
|
+
description += "..."
|
|
33
|
+
|
|
34
|
+
content.append(f"\n - {tool.name}: {description}\n")
|
|
35
|
+
|
|
36
|
+
# Get parameters from the action type schema
|
|
37
|
+
try:
|
|
38
|
+
params_dict = tool.action_type.to_mcp_schema()
|
|
39
|
+
params_str = json.dumps(params_dict)
|
|
40
|
+
if len(params_str) > 200:
|
|
41
|
+
params_str = params_str[:197] + "..."
|
|
42
|
+
content.append(f" Parameters: {params_str}")
|
|
43
|
+
except Exception:
|
|
44
|
+
content.append(" Parameters: <unavailable>")
|
|
45
|
+
return content
|
|
46
|
+
|
|
47
|
+
def to_llm_message(self) -> Message:
|
|
48
|
+
return Message(role="system", content=[self.system_prompt])
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
"""Plain text string representation for SystemPromptEvent."""
|
|
52
|
+
base_str = f"{self.__class__.__name__} ({self.source})"
|
|
53
|
+
prompt_preview = (
|
|
54
|
+
self.system_prompt.text[:N_CHAR_PREVIEW] + "..."
|
|
55
|
+
if len(self.system_prompt.text) > N_CHAR_PREVIEW
|
|
56
|
+
else self.system_prompt.text
|
|
57
|
+
)
|
|
58
|
+
tool_count = len(self.tools)
|
|
59
|
+
return (
|
|
60
|
+
f"{base_str}\n System: {prompt_preview}\n Tools: {tool_count} available"
|
|
61
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
|
|
3
|
+
from openhands.sdk.event.base import Event
|
|
4
|
+
from openhands.sdk.event.types import SourceType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TokenEvent(Event):
|
|
8
|
+
"""Event from VLLM representing token IDs used in LLM interaction."""
|
|
9
|
+
|
|
10
|
+
source: SourceType
|
|
11
|
+
prompt_token_ids: list[int] = Field(
|
|
12
|
+
..., description="The exact prompt token IDs for this message event"
|
|
13
|
+
)
|
|
14
|
+
response_token_ids: list[int] = Field(
|
|
15
|
+
..., description="The exact response token IDs for this message event"
|
|
16
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
EventType = Literal["action", "observation", "message", "system_prompt", "agent_error"]
|
|
5
|
+
SourceType = Literal["agent", "user", "environment"]
|
|
6
|
+
|
|
7
|
+
EventID = str
|
|
8
|
+
"""Type alias for event IDs."""
|
|
9
|
+
|
|
10
|
+
ToolCallID = str
|
|
11
|
+
"""Type alias for tool call IDs."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from rich.text import Text
|
|
2
|
+
|
|
3
|
+
from openhands.sdk.event.base import Event
|
|
4
|
+
from openhands.sdk.event.types import SourceType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PauseEvent(Event):
|
|
8
|
+
"""Event indicating that the agent execution was paused by user request."""
|
|
9
|
+
|
|
10
|
+
source: SourceType = "user"
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def visualize(self) -> Text:
|
|
14
|
+
"""Return Rich Text representation of this pause event."""
|
|
15
|
+
content = Text()
|
|
16
|
+
content.append("Conversation Paused", style="bold")
|
|
17
|
+
return content
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
"""Plain text string representation for PauseEvent."""
|
|
21
|
+
return f"{self.__class__.__name__} ({self.source}): Agent execution paused"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Git-related exceptions for OpenHands SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GitError(Exception):
|
|
5
|
+
"""Base exception for git-related errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitRepositoryError(GitError):
|
|
11
|
+
"""Exception raised when git repository operations fail."""
|
|
12
|
+
|
|
13
|
+
command: str | None
|
|
14
|
+
exit_code: int | None
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self, message: str, command: str | None = None, exit_code: int | None = None
|
|
18
|
+
):
|
|
19
|
+
self.command = command
|
|
20
|
+
self.exit_code = exit_code
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GitCommandError(GitError):
|
|
25
|
+
"""Exception raised when git command execution fails."""
|
|
26
|
+
|
|
27
|
+
command: list[str]
|
|
28
|
+
exit_code: int
|
|
29
|
+
stderr: str
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self, message: str, command: list[str], exit_code: int, stderr: str = ""
|
|
33
|
+
):
|
|
34
|
+
self.command = command
|
|
35
|
+
self.exit_code = exit_code
|
|
36
|
+
self.stderr = stderr
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitPathError(GitError):
|
|
41
|
+
"""Exception raised when git path operations fail."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Get git changes in the current working directory relative to the remote origin
|
|
3
|
+
if possible.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from openhands.sdk.git.exceptions import GitCommandError
|
|
13
|
+
from openhands.sdk.git.models import GitChange, GitChangeStatus
|
|
14
|
+
from openhands.sdk.git.utils import (
|
|
15
|
+
get_valid_ref,
|
|
16
|
+
run_git_command,
|
|
17
|
+
validate_git_repository,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _map_git_status_to_enum(status: str) -> GitChangeStatus:
|
|
25
|
+
"""Map git status codes to GitChangeStatus enum values."""
|
|
26
|
+
status_mapping = {
|
|
27
|
+
"M": GitChangeStatus.UPDATED,
|
|
28
|
+
"A": GitChangeStatus.ADDED,
|
|
29
|
+
"D": GitChangeStatus.DELETED,
|
|
30
|
+
"U": GitChangeStatus.UPDATED, # Unmerged files are treated as updated
|
|
31
|
+
}
|
|
32
|
+
if status not in status_mapping:
|
|
33
|
+
raise ValueError(f"Unknown git status: {status}")
|
|
34
|
+
return status_mapping[status]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_changes_in_repo(repo_dir: str | Path) -> list[GitChange]:
|
|
38
|
+
"""Get git changes in a repository relative to the origin default branch.
|
|
39
|
+
|
|
40
|
+
This is different from `git status` as it compares against the remote branch
|
|
41
|
+
rather than the staging area.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
repo_dir: Path to the git repository
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
List of GitChange objects representing the changes
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
GitRepositoryError: If the directory is not a valid git repository
|
|
51
|
+
GitCommandError: If git commands fail
|
|
52
|
+
"""
|
|
53
|
+
# Validate the repository first
|
|
54
|
+
validated_repo = validate_git_repository(repo_dir)
|
|
55
|
+
|
|
56
|
+
ref = get_valid_ref(validated_repo)
|
|
57
|
+
if not ref:
|
|
58
|
+
logger.warning(f"No valid git reference found for {validated_repo}")
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
# Get changed files using secure git command
|
|
62
|
+
try:
|
|
63
|
+
changed_files_output = run_git_command(
|
|
64
|
+
["git", "--no-pager", "diff", "--name-status", ref], validated_repo
|
|
65
|
+
)
|
|
66
|
+
changed_files = (
|
|
67
|
+
changed_files_output.splitlines() if changed_files_output else []
|
|
68
|
+
)
|
|
69
|
+
except GitCommandError as e:
|
|
70
|
+
logger.error(f"Failed to get git diff for {validated_repo}: {e}")
|
|
71
|
+
raise
|
|
72
|
+
changes = []
|
|
73
|
+
for line in changed_files:
|
|
74
|
+
if not line.strip():
|
|
75
|
+
logger.warning("Empty line in git diff output, skipping")
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Handle different output formats from git diff --name-status
|
|
79
|
+
# Depending on git config, format can be either:
|
|
80
|
+
# * "A file.txt"
|
|
81
|
+
# * "A file.txt"
|
|
82
|
+
# * "R100 old_file.txt new_file.txt" (rename with similarity percentage)
|
|
83
|
+
parts = line.split()
|
|
84
|
+
if len(parts) < 2:
|
|
85
|
+
logger.error(f"Unexpected git diff line format: {line}")
|
|
86
|
+
raise GitCommandError(
|
|
87
|
+
message=f"Unexpected git diff output format: {line}",
|
|
88
|
+
command=["git", "diff", "--name-status"],
|
|
89
|
+
exit_code=0,
|
|
90
|
+
stderr="Invalid output format",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
status = parts[0].strip()
|
|
94
|
+
|
|
95
|
+
# Handle rename operations (status starts with 'R' followed
|
|
96
|
+
# by similarity percentage)
|
|
97
|
+
if status.startswith("R") and len(parts) == 3:
|
|
98
|
+
# Rename: convert to delete (old path) + add (new path)
|
|
99
|
+
old_path = parts[1].strip()
|
|
100
|
+
new_path = parts[2].strip()
|
|
101
|
+
changes.append(
|
|
102
|
+
GitChange(
|
|
103
|
+
status=GitChangeStatus.DELETED,
|
|
104
|
+
path=Path(old_path),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
changes.append(
|
|
108
|
+
GitChange(
|
|
109
|
+
status=GitChangeStatus.ADDED,
|
|
110
|
+
path=Path(new_path),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
logger.debug(f"Found git rename: {old_path} -> {new_path}")
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Handle copy operations (status starts with 'C' followed by
|
|
117
|
+
# similarity percentage)
|
|
118
|
+
elif status.startswith("C") and len(parts) == 3:
|
|
119
|
+
# Copy: only add the new path (original remains)
|
|
120
|
+
new_path = parts[2].strip()
|
|
121
|
+
changes.append(
|
|
122
|
+
GitChange(
|
|
123
|
+
status=GitChangeStatus.ADDED,
|
|
124
|
+
path=Path(new_path),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
logger.debug(f"Found git copy: -> {new_path}")
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
# Handle regular operations (M, A, D, etc.)
|
|
131
|
+
elif len(parts) == 2:
|
|
132
|
+
path = parts[1].strip()
|
|
133
|
+
else:
|
|
134
|
+
logger.error(f"Unexpected git diff line format: {line}")
|
|
135
|
+
raise GitCommandError(
|
|
136
|
+
message=f"Unexpected git diff output format: {line}",
|
|
137
|
+
command=["git", "diff", "--name-status"],
|
|
138
|
+
exit_code=0,
|
|
139
|
+
stderr="Invalid output format",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if status == "??":
|
|
143
|
+
status = "A"
|
|
144
|
+
elif status == "*":
|
|
145
|
+
status = "M"
|
|
146
|
+
|
|
147
|
+
# Check for valid single-character status codes
|
|
148
|
+
if status in {"M", "A", "D", "U"}:
|
|
149
|
+
try:
|
|
150
|
+
changes.append(
|
|
151
|
+
GitChange(
|
|
152
|
+
status=_map_git_status_to_enum(status),
|
|
153
|
+
path=Path(path),
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
logger.debug(f"Found git change: {status} {path}")
|
|
157
|
+
except ValueError as e:
|
|
158
|
+
logger.error(f"Unknown git status '{status}' for file {path}")
|
|
159
|
+
raise GitCommandError(
|
|
160
|
+
message=f"Unknown git status: {status}",
|
|
161
|
+
command=["git", "diff", "--name-status"],
|
|
162
|
+
exit_code=0,
|
|
163
|
+
stderr=f"Unknown status code: {status}",
|
|
164
|
+
) from e
|
|
165
|
+
else:
|
|
166
|
+
logger.error(f"Unexpected git status '{status}' for file {path}")
|
|
167
|
+
raise GitCommandError(
|
|
168
|
+
message=f"Unexpected git status: {status}",
|
|
169
|
+
command=["git", "diff", "--name-status"],
|
|
170
|
+
exit_code=0,
|
|
171
|
+
stderr=f"Unexpected status code: {status}",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Get untracked files
|
|
175
|
+
try:
|
|
176
|
+
untracked_output = run_git_command(
|
|
177
|
+
["git", "--no-pager", "ls-files", "--others", "--exclude-standard"],
|
|
178
|
+
validated_repo,
|
|
179
|
+
)
|
|
180
|
+
untracked_files = untracked_output.splitlines() if untracked_output else []
|
|
181
|
+
except GitCommandError as e:
|
|
182
|
+
logger.error(f"Failed to get untracked files for {validated_repo}: {e}")
|
|
183
|
+
untracked_files = []
|
|
184
|
+
for path in untracked_files:
|
|
185
|
+
if path.strip():
|
|
186
|
+
changes.append(
|
|
187
|
+
GitChange(
|
|
188
|
+
status=GitChangeStatus.ADDED,
|
|
189
|
+
path=Path(path.strip()),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
logger.debug(f"Found untracked file: {path}")
|
|
193
|
+
|
|
194
|
+
logger.info(f"Found {len(changes)} total git changes in {validated_repo}")
|
|
195
|
+
return changes
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_git_changes(cwd: str | Path) -> list[GitChange]:
|
|
199
|
+
git_dirs = {
|
|
200
|
+
os.path.dirname(f)[2:]
|
|
201
|
+
for f in glob.glob("./*/.git", root_dir=cwd, recursive=True)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# First try the workspace directory
|
|
205
|
+
changes = get_changes_in_repo(cwd)
|
|
206
|
+
|
|
207
|
+
# Filter out any changes which are in one of the git directories
|
|
208
|
+
changes = [
|
|
209
|
+
change
|
|
210
|
+
for change in changes
|
|
211
|
+
if next(
|
|
212
|
+
iter(
|
|
213
|
+
git_dir for git_dir in git_dirs if str(change.path).startswith(git_dir)
|
|
214
|
+
),
|
|
215
|
+
None,
|
|
216
|
+
)
|
|
217
|
+
is None
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
# Add changes from git directories
|
|
221
|
+
for git_dir in git_dirs:
|
|
222
|
+
git_dir_changes = get_changes_in_repo(str(Path(cwd, git_dir)))
|
|
223
|
+
for change in git_dir_changes:
|
|
224
|
+
# Create a new GitChange with the updated path
|
|
225
|
+
updated_change = GitChange(
|
|
226
|
+
status=change.status,
|
|
227
|
+
path=Path(git_dir) / change.path,
|
|
228
|
+
)
|
|
229
|
+
changes.append(updated_change)
|
|
230
|
+
|
|
231
|
+
changes.sort(key=lambda change: str(change.path))
|
|
232
|
+
|
|
233
|
+
return changes
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
if __name__ == "__main__":
|
|
237
|
+
try:
|
|
238
|
+
changes = get_git_changes(os.getcwd())
|
|
239
|
+
# Convert GitChange objects to dictionaries for JSON serialization
|
|
240
|
+
changes_dict = [
|
|
241
|
+
{
|
|
242
|
+
"status": change.status.value,
|
|
243
|
+
"path": str(change.path),
|
|
244
|
+
}
|
|
245
|
+
for change in changes
|
|
246
|
+
]
|
|
247
|
+
print(json.dumps(changes_dict))
|
|
248
|
+
except Exception as e:
|
|
249
|
+
print(json.dumps({"error": str(e)}))
|