Compare commits

..

10 Commits

Author SHA1 Message Date
chuckbutkus 4d1cdb976e Merge branch 'main' into custom-username 2025-09-03 21:45:46 -04:00
Chuck Butkus 1f09296136 Fix username checks 2025-09-03 21:40:13 -04:00
Chuck Butkus 70e5d12ba9 Revert "Change to a non-login shell"
This reverts commit bcb3160d95.
2025-08-29 01:48:47 -04:00
Chuck Butkus bcb3160d95 Change to a non-login shell 2025-08-29 01:37:02 -04:00
Chuck Butkus 174c691744 Update 2025-08-28 02:25:05 -04:00
Chuck Butkus af34d446e9 Remove vscode username restriction 2025-08-28 02:22:27 -04:00
Chuck Butkus 6604924f76 Fix bash username 2025-08-28 02:21:41 -04:00
chuckbutkus b2def1e438 Merge branch 'main' into test-user 2025-08-27 23:33:45 -04:00
Chuck Butkus 2b8e47aca9 Add runtime user env vars 2025-08-27 23:02:39 -04:00
Chuck Butkus dba8b28824 Logging 2025-08-27 21:30:47 -04:00
36 changed files with 132 additions and 571 deletions
-13
View File
@@ -1,13 +0,0 @@
# Task List
1. Verify PR#10432 item 11: serialization reasoning_content precedence matches reviewer intent
- id: 11-verify-serialization-reasoning-precedence
- File: openhands/events/serialization/action.py
- Reviewer asked to prefer top-level rc; current implementation may differ.
- Status: todo
2. Verify PR#10432 item 12: conversation_memory uses structured thought without legacy hasattr/getattr checks
- id: 12-verify-memory-use-structured-thought
- File: openhands/memory/conversation_memory.py
- Reviewer asked to directly use action.thought; code may retain legacy guards.
- Status: todo
@@ -29,7 +29,7 @@ describe("EventMessage", () => {
args: {
final_thought: "Task completed successfully",
outputs: {},
thought: { text: "Task completed successfully" },
thought: "Task completed successfully",
},
message: "Task completed successfully",
timestamp: new Date().toISOString(),
@@ -55,7 +55,7 @@ describe("EventMessage", () => {
source: "agent" as const,
action: "message" as const,
args: {
thought: { text: "I need more information to proceed." },
thought: "I need more information to proceed.",
image_urls: null,
file_urls: [],
wait_for_response: true,
@@ -114,7 +114,7 @@ describe("EventMessage", () => {
args: {
final_thought: "Task completed successfully",
outputs: {},
thought: { text: "Task completed successfully" },
thought: "Task completed successfully",
},
message: "Task completed successfully",
timestamp: new Date().toISOString(),
@@ -58,7 +58,7 @@ describe("Messages", () => {
args: {
image_urls: [],
file_urls: [],
thought: { text: "" },
thought: "",
wait_for_response: false,
},
};
@@ -67,14 +67,16 @@ const getMcpActionContent = (event: MCPAction): string => {
const name = event.args.name || "";
const args = event.args.arguments || {};
let details = `**MCP Tool Call:** ${name}\n\n`;
details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``;
// Include thought if available
if (event.args.thought) {
details += `\n\n**Thought:**\n${event.args.thought}`;
}
details += `\n\n**Arguments:**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``;
return details;
};
const getThinkActionContent = (event: ThinkAction): string => {
const t = event.args.thought;
return t.reasoning_content ? `${t.reasoning_content}\n\n${t.text}` : t.text;
};
const getThinkActionContent = (event: ThinkAction): string =>
event.args.thought;
const getFinishActionContent = (event: FinishAction): string =>
event.args.final_thought.trim();
@@ -33,20 +33,7 @@ import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
const hasThoughtProperty = (
obj: Record<string, unknown>,
): obj is {
thought?: { text?: string; reasoning_content?: string | null };
} => {
const { thought } = obj;
if (!thought || typeof thought !== "object") return false;
const { text = "", reasoning_content: rc } = thought as {
text?: string;
reasoning_content?: string | null;
};
return (
(typeof text === "string" && text.length > 0) ||
(typeof rc === "string" && rc.length > 0)
);
};
): obj is { thought: string } => "thought" in obj && !!obj.thought;
interface EventMessageProps {
event: OpenHandsAction | OpenHandsObservation;
@@ -134,20 +121,11 @@ export function EventMessage({
if (hasThoughtProperty(event.args) && event.action !== "think") {
return (
<div>
{event.args.thought?.reasoning_content && (
<GenericEventMessage
title={t("ACTION_MESSAGE$REASONING")}
details={event.args.thought.reasoning_content}
initiallyExpanded={false}
/>
)}
{(event.args.thought?.text || "") !== "" && (
<ChatMessage
type="agent"
message={event.args.thought?.text || ""}
actions={actions}
/>
)}
<ChatMessage
type="agent"
message={event.args.thought}
actions={actions}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
@@ -170,13 +148,6 @@ export function EventMessage({
if (isFinishAction(event)) {
return (
<>
{event.args.thought?.reasoning_content && (
<GenericEventMessage
title="Reasoning"
details={event.args.thought.reasoning_content}
initiallyExpanded={false}
/>
)}
<ChatMessage
type="agent"
message={getEventContent(event).details}
@@ -276,21 +247,7 @@ export function EventMessage({
{isOpenHandsAction(event) &&
hasThoughtProperty(event.args) &&
event.action !== "think" && (
<>
{event.args.thought?.reasoning_content && (
<GenericEventMessage
title={t("ACTION_MESSAGE$REASONING")}
details={event.args.thought.reasoning_content}
initiallyExpanded={false}
/>
)}
{(event.args.thought?.text || "") !== "" && (
<ChatMessage
type="agent"
message={event.args.thought?.text || ""}
/>
)}
</>
<ChatMessage type="agent" message={event.args.thought} />
)}
<GenericEventMessage
-1
View File
@@ -816,7 +816,6 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW = "MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW",
MICROAGENT_MANAGEMENT$PR_NOT_CREATED = "MICROAGENT_MANAGEMENT$PR_NOT_CREATED",
MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT = "MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT",
ACTION_MESSAGE$REASONING = "ACTION_MESSAGE$REASONING",
MICROAGENT$STATUS_WAITING = "MICROAGENT$STATUS_WAITING",
MICROAGENT$UNKNOWN_ERROR = "MICROAGENT$UNKNOWN_ERROR",
MICROAGENT$CONVERSATION_STARTING = "MICROAGENT$CONVERSATION_STARTING",
-16
View File
@@ -13055,22 +13055,6 @@
"de": "Etwas ist schiefgelaufen. Versuchen Sie, den Microagenten erneut zu starten.",
"uk": "Щось пішло не так. Спробуйте ініціювати мікроагента ще раз."
},
"ACTION_MESSAGE$REASONING": {
"en": "Reasoning",
"ja": "推論",
"zh-CN": "推理",
"zh-TW": "推理",
"ko-KR": "추론",
"no": "Resonnement",
"ar": "التفكير",
"de": "Begründung",
"fr": "Raisonnement",
"it": "Ragionamento",
"pt": "Raciocínio",
"es": "Razonamiento",
"tr": "Akıl Yürütme",
"uk": "Міркування"
},
"MICROAGENT$STATUS_WAITING": {
"en": "Waiting for runtime to start...",
"ja": "ランタイムの開始を待機中...",
+1 -1
View File
@@ -29,7 +29,7 @@ export const generateAssistantMessageAction = (
timestamp: new Date().toISOString(),
action: "message",
args: {
thought: { text: message },
thought: message,
image_urls: [],
file_urls: [],
wait_for_response: false,
+15 -16
View File
@@ -1,6 +1,5 @@
import { OpenHandsActionEvent } from "./base";
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import { Thought } from "./thought";
export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
source: "user";
@@ -27,7 +26,7 @@ export interface CommandAction extends OpenHandsActionEvent<"run"> {
command: string;
security_risk: ActionSecurityRisk;
confirmation_state: "confirmed" | "rejected" | "awaiting_confirmation";
thought: Thought;
thought: string;
hidden?: boolean;
};
}
@@ -36,7 +35,7 @@ export interface AssistantMessageAction
extends OpenHandsActionEvent<"message"> {
source: "agent";
args: {
thought: Thought;
thought: string;
image_urls: string[] | null;
file_urls: string[];
wait_for_response: boolean;
@@ -50,14 +49,14 @@ export interface IPythonAction extends OpenHandsActionEvent<"run_ipython"> {
security_risk: ActionSecurityRisk;
confirmation_state: "confirmed" | "rejected" | "awaiting_confirmation";
kernel_init_code: string;
thought: Thought;
thought: string;
};
}
export interface ThinkAction extends OpenHandsActionEvent<"think"> {
source: "agent";
args: {
thought: Thought;
thought: string;
};
}
@@ -66,7 +65,7 @@ export interface FinishAction extends OpenHandsActionEvent<"finish"> {
args: {
final_thought: string;
outputs: Record<string, unknown>;
thought: Thought;
thought: string;
};
}
@@ -76,7 +75,7 @@ export interface DelegateAction extends OpenHandsActionEvent<"delegate"> {
args: {
agent: "BrowsingAgent";
inputs: Record<string, string>;
thought: Thought;
thought: string;
};
}
@@ -84,7 +83,7 @@ export interface BrowseAction extends OpenHandsActionEvent<"browse"> {
source: "agent";
args: {
url: string;
thought: Thought;
thought: string;
};
}
@@ -94,7 +93,7 @@ export interface BrowseInteractiveAction
timeout: number;
args: {
browser_actions: string;
thought: Thought | null;
thought: string | null;
browsergym_send_msg_to_user: string;
};
}
@@ -103,7 +102,7 @@ export interface FileReadAction extends OpenHandsActionEvent<"read"> {
source: "agent";
args: {
path: string;
thought: Thought;
thought: string;
security_risk: ActionSecurityRisk | null;
impl_source?: string;
view_range?: number[] | null;
@@ -115,7 +114,7 @@ export interface FileWriteAction extends OpenHandsActionEvent<"write"> {
args: {
path: string;
content: string;
thought: Thought;
thought: string;
};
}
@@ -132,7 +131,7 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> {
content?: string;
start?: number;
end?: number;
thought: Thought;
thought: string;
security_risk: ActionSecurityRisk | null;
impl_source?: string;
};
@@ -141,7 +140,7 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> {
export interface RejectAction extends OpenHandsActionEvent<"reject"> {
source: "agent";
args: {
thought: Thought;
thought: string;
};
}
@@ -150,7 +149,7 @@ export interface RecallAction extends OpenHandsActionEvent<"recall"> {
args: {
recall_type: "workspace_context" | "knowledge";
query: string;
thought: Thought;
thought: string;
};
}
@@ -159,7 +158,7 @@ export interface MCPAction extends OpenHandsActionEvent<"call_tool_mcp"> {
args: {
name: string;
arguments: Record<string, unknown>;
thought?: Thought;
thought?: string;
};
}
@@ -174,7 +173,7 @@ export interface TaskTrackingAction
status: "todo" | "in_progress" | "done";
notes?: string;
}>;
thought: Thought;
thought: string;
};
}
-4
View File
@@ -1,4 +0,0 @@
export interface Thought {
text: string;
reasoning_content?: string | null;
}
@@ -6,7 +6,6 @@ from openhands.core.logger import openhands_logger as logger
from openhands.events.action import (
Action,
BrowseInteractiveAction,
Thought,
)
@@ -63,10 +62,9 @@ class BrowsingActionParserMessage(ActionParser):
def parse(self, action_str: str) -> Action:
msg = f'send_msg_to_user("""{action_str}""")'
return BrowseInteractiveAction(
browser_actions=msg,
thought=Thought(text=action_str),
thought=action_str,
browsergym_send_msg_to_user=action_str,
)
@@ -123,6 +121,6 @@ class BrowsingActionParserBrowseInteractive(ActionParser):
return BrowseInteractiveAction(
browser_actions=browser_actions,
thought=Thought(text=thought),
thought=thought,
browsergym_send_msg_to_user=msg_content,
)
@@ -38,7 +38,6 @@ from openhands.events.action import (
IPythonRunCellAction,
MessageAction,
TaskTrackingAction,
Thought,
)
from openhands.events.action.agent import CondensationRequestAction
from openhands.events.action.mcp import MCPAction
@@ -47,24 +46,13 @@ from openhands.events.tool import ToolCallMetadata
from openhands.llm.tool_names import TASK_TRACKER_TOOL_NAME
def combine_thought(
action: Action, thought: str, reasoning_content: str | None = None
) -> Action:
def combine_thought(action: Action, thought: str) -> Action:
if not hasattr(action, 'thought'):
return action
current_thought = action.thought
# Always normalize to Thought for downstream code
if not isinstance(current_thought, Thought):
current_thought = Thought(text=str(current_thought) if current_thought else '')
action.thought = current_thought
# We have a Thought, so we can update it
cur_text = current_thought.text or ''
if thought:
current_thought.text = f'{thought}\n{cur_text}' if cur_text else thought
if reasoning_content is not None:
current_thought.reasoning_content = reasoning_content
if thought and action.thought:
action.thought = f'{thought}\n{action.thought}'
elif thought:
action.thought = thought
return action
@@ -92,26 +80,12 @@ def response_to_actions(
if hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls:
# Check if there's assistant_msg.content. If so, add it to the thought
thought = ''
reasoning_content: str | None = None
if isinstance(assistant_msg.content, str):
thought = assistant_msg.content
elif isinstance(assistant_msg.content, list):
for msg in assistant_msg.content:
if msg['type'] == 'text':
thought += msg['text']
# Capture optional reasoning content if provided by the model
if msg.get('type') in {'reasoning', 'thinking'} and 'text' in msg:
reasoning_content = (
reasoning_content + '\n' if reasoning_content else ''
) + msg['text']
# Also try direct attributes from LiteLLM message wrapper
for attr in ('reasoning_content', 'reasoning', 'thinking'):
rc = getattr(assistant_msg, attr, None)
if isinstance(rc, str) and rc.strip():
reasoning_content = (
rc if not reasoning_content else reasoning_content + '\n' + rc
)
# Process each tool call to OpenHands action
for i, tool_call in enumerate(assistant_msg.tool_calls):
@@ -257,9 +231,7 @@ def response_to_actions(
# AgentThinkAction
# ================================================
elif tool_call.function.name == ThinkTool['function']['name']:
action = AgentThinkAction(
thought=Thought(text=arguments.get('thought', ''))
)
action = AgentThinkAction(thought=arguments.get('thought', ''))
# ================================================
# CondensationRequestAction
@@ -338,7 +310,7 @@ def response_to_actions(
# We only add thought to the first action
if i == 0:
action = combine_thought(action, thought, reasoning_content)
action = combine_thought(action, thought)
# Add metadata for tool calling
action.tool_call_metadata = ToolCallMetadata(
tool_call_id=tool_call.id,
+1 -2
View File
@@ -12,7 +12,6 @@ from openhands.events.action import (
FileReadAction,
FileWriteAction,
MessageAction,
Thought,
)
from openhands.events.observation import (
AgentStateChangedObservation,
@@ -92,7 +91,7 @@ class DummyAgent(Agent):
},
{
'action': AgentFinishAction(
outputs={}, thought=Thought(text='Task completed'), action='finish'
outputs={}, thought='Task completed', action='finish'
),
'observations': [AgentStateChangedObservation('', AgentState.FINISHED)],
},
@@ -42,23 +42,12 @@ def response_to_actions(
if hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls:
# Check if there's assistant_msg.content. If so, add it to the thought
thought = ''
reasoning_content: str | None = None
if isinstance(assistant_msg.content, str):
thought = assistant_msg.content
elif isinstance(assistant_msg.content, list):
for msg in assistant_msg.content:
if msg['type'] == 'text':
thought += msg['text']
if msg.get('type') in {'reasoning', 'thinking'} and 'text' in msg:
reasoning_content = (
reasoning_content + '\n' if reasoning_content else ''
) + msg['text']
for attr in ('reasoning_content', 'reasoning', 'thinking'):
rc = getattr(assistant_msg, attr, None)
if isinstance(rc, str) and rc.strip():
reasoning_content = (
rc if not reasoning_content else reasoning_content + '\n' + rc
)
# Process each tool call to OpenHands action
for i, tool_call in enumerate(assistant_msg.tool_calls):
@@ -100,7 +89,7 @@ def response_to_actions(
# We only add thought to the first action
if i == 0:
action = combine_thought(action, thought, reasoning_content)
action = combine_thought(action, thought)
# Add metadata for tool calling
action.tool_call_metadata = ToolCallMetadata(
tool_call_id=tool_call.id,
@@ -36,7 +36,6 @@ from openhands.events.action import (
FileReadAction,
MCPAction,
MessageAction,
Thought,
)
from openhands.events.event import FileReadSource
from openhands.events.tool import ToolCallMetadata
@@ -118,23 +117,12 @@ def response_to_actions(
if hasattr(assistant_msg, 'tool_calls') and assistant_msg.tool_calls:
# Check if there's assistant_msg.content. If so, add it to the thought
thought = ''
reasoning_content: str | None = None
if isinstance(assistant_msg.content, str):
thought = assistant_msg.content
elif isinstance(assistant_msg.content, list):
for msg in assistant_msg.content:
if msg['type'] == 'text':
thought += msg['text']
if msg.get('type') in {'reasoning', 'thinking'} and 'text' in msg:
reasoning_content = (
reasoning_content + '\n' if reasoning_content else ''
) + msg['text']
for attr in ('reasoning_content', 'reasoning', 'thinking'):
rc = getattr(assistant_msg, attr, None)
if isinstance(rc, str) and rc.strip():
reasoning_content = (
rc if not reasoning_content else reasoning_content + '\n' + rc
)
# Process each tool call to OpenHands action
for i, tool_call in enumerate(assistant_msg.tool_calls):
@@ -173,9 +161,7 @@ def response_to_actions(
# AgentThinkAction
# ================================================
elif tool_call.function.name == ThinkTool['function']['name']:
action = AgentThinkAction(
thought=Thought(text=arguments.get('thought', ''))
)
action = AgentThinkAction(thought=arguments.get('thought', ''))
# ================================================
# GrepTool (file content search)
@@ -224,7 +210,7 @@ def response_to_actions(
# We only add thought to the first action
if i == 0:
action = combine_thought(action, thought, reasoning_content)
action = combine_thought(action, thought)
# Add metadata for tool calling
action.tool_call_metadata = ToolCallMetadata(
tool_call_id=tool_call.id,
+3 -3
View File
@@ -263,7 +263,7 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
if isinstance(event, CmdRunAction):
# For CmdRunAction, display thought first, then command
if hasattr(event, 'thought') and event.thought:
display_thought_if_new(str(event.thought))
display_thought_if_new(event.thought)
# Only display the command if it's not already confirmed
# Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
@@ -279,7 +279,7 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
elif isinstance(event, Action):
# For other actions, display thoughts normally
if hasattr(event, 'thought') and event.thought:
display_thought_if_new(str(event.thought))
display_thought_if_new(event.thought)
if hasattr(event, 'final_thought') and event.final_thought:
# Display final thoughts with agent styling
display_message(event.final_thought, is_agent_message=True)
@@ -522,7 +522,7 @@ def display_task_tracking_action(event: TaskTrackingAction) -> None:
"""Display a TaskTracking action in the CLI."""
# Display thought first if present
if hasattr(event, 'thought') and event.thought:
display_thought_if_new(str(event.thought))
display_thought_if_new(event.thought)
# Format the command and task list for display
display_text = f'Command: {event.command}'
+1 -2
View File
@@ -659,8 +659,7 @@ class AgentController:
new_state in (AgentState.USER_CONFIRMED, AgentState.USER_REJECTED)
):
if hasattr(self._pending_action, 'thought'):
# Clear the thought text for confirmed/rejected actions
self._pending_action.thought.text = '' # type: ignore[attr-defined]
self._pending_action.thought = '' # type: ignore[union-attr]
if new_state == AgentState.USER_CONFIRMED:
confirmation_state = ActionConfirmationStatus.CONFIRMED
else:
-2
View File
@@ -2,7 +2,6 @@ from openhands.events.action.action import (
Action,
ActionConfirmationStatus,
ActionSecurityRisk,
Thought,
)
from openhands.events.action.agent import (
AgentDelegateAction,
@@ -45,6 +44,5 @@ __all__ = [
'RecallAction',
'MCPAction',
'TaskTrackingAction',
'Thought',
'ActionSecurityRisk',
]
-36
View File
@@ -21,39 +21,3 @@ class ActionSecurityRisk(int, Enum):
@dataclass
class Action(Event):
runnable: ClassVar[bool] = False
@dataclass
class Thought:
"""Container for agent reasoning.
Attributes:
text: The visible plain thought string used throughout the UI/logs.
reasoning_content: Optional provider-native reasoning content (e.g., OpenAI reasoning).
"""
text: str = ''
reasoning_content: str | None = None
def __bool__(self) -> bool:
return bool(self.text or self.reasoning_content)
def __str__(self) -> str:
# Concatenate provider-native reasoning content and visible text for display.
# Do not rely on this for content sent to the LLM; conversation_memory must use .text only.
if self.reasoning_content and self.text:
return f'{self.reasoning_content}\n\n{self.text}'
if self.reasoning_content:
return self.reasoning_content
return self.text
def __eq__(self, other: object) -> bool: # type: ignore[override]
# Allow comparing Thought to plain strings for backward compatibility in tests/UI code
if isinstance(other, Thought):
return (
self.text == other.text
and self.reasoning_content == other.reasoning_content
)
if isinstance(other, str):
return self.text == other
return NotImplemented # type: ignore[return-value]
+10 -10
View File
@@ -2,7 +2,7 @@ from dataclasses import dataclass, field
from typing import Any
from openhands.core.schema import ActionType
from openhands.events.action import Action, Thought
from openhands.events.action.action import Action
from openhands.events.event import RecallType
@@ -11,7 +11,7 @@ class ChangeAgentStateAction(Action):
"""Fake action, just to notify the client that a task state has changed."""
agent_state: str
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.CHANGE_AGENT_STATE
@property
@@ -32,13 +32,13 @@ class AgentFinishAction(Action):
final_thought: str = ''
outputs: dict[str, Any] = field(default_factory=dict)
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.FINISH
@property
def message(self) -> str:
if self.thought and str(self.thought) != '':
return str(self.thought)
if self.thought != '':
return self.thought
return "All done! What's next on the agenda?"
@@ -51,7 +51,7 @@ class AgentThinkAction(Action):
action (str): The action type, namely ActionType.THINK.
"""
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.THINK
@property
@@ -62,7 +62,7 @@ class AgentThinkAction(Action):
@dataclass
class AgentRejectAction(Action):
outputs: dict = field(default_factory=dict)
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.REJECT
@property
@@ -77,7 +77,7 @@ class AgentRejectAction(Action):
class AgentDelegateAction(Action):
agent: str
inputs: dict
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.DELEGATE
@property
@@ -91,7 +91,7 @@ class RecallAction(Action):
recall_type: RecallType
query: str = ''
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.RECALL
@property
@@ -214,7 +214,7 @@ class TaskTrackingAction(Action):
command: str = 'view'
task_list: list[dict[str, Any]] = field(default_factory=list)
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.TASK_TRACKING
@property
+4 -4
View File
@@ -1,14 +1,14 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import ClassVar
from openhands.core.schema import ActionType
from openhands.events.action import Action, ActionSecurityRisk, Thought
from openhands.events.action.action import Action, ActionSecurityRisk
@dataclass
class BrowseURLAction(Action):
url: str
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.BROWSE
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
@@ -29,7 +29,7 @@ class BrowseURLAction(Action):
@dataclass
class BrowseInteractiveAction(Action):
browser_actions: str
thought: Thought = field(default_factory=Thought)
thought: str = ''
browsergym_send_msg_to_user: str = ''
action: str = ActionType.BROWSE_INTERACTIVE
runnable: ClassVar[bool] = True
+3 -4
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import ClassVar
from openhands.core.schema import ActionType
@@ -6,7 +6,6 @@ from openhands.events.action.action import (
Action,
ActionConfirmationStatus,
ActionSecurityRisk,
Thought,
)
@@ -16,7 +15,7 @@ class CmdRunAction(Action):
str # When `command` is empty, it will be used to print the current tmux window
)
is_input: bool = False # if True, the command is an input to the running process
thought: Thought = field(default_factory=Thought)
thought: str = ''
blocking: bool = False # if True, the command will be run in a blocking manner, but a timeout must be set through _set_hard_timeout
is_static: bool = False # if True, runs the command in a separate process
cwd: str | None = None # current working directory, only used if is_static is True
@@ -43,7 +42,7 @@ class CmdRunAction(Action):
@dataclass
class IPythonRunCellAction(Action):
code: str
thought: Thought = field(default_factory=Thought)
thought: str = ''
include_extra: bool = (
True # whether to include CWD & Python interpreter in the output
)
+5 -5
View File
@@ -1,8 +1,8 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import ClassVar
from openhands.core.schema import ActionType
from openhands.events.action import Action, ActionSecurityRisk, Thought
from openhands.events.action.action import Action, ActionSecurityRisk
from openhands.events.event import FileEditSource, FileReadSource
@@ -16,7 +16,7 @@ class FileReadAction(Action):
path: str
start: int = 0
end: int = -1
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.READ
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
@@ -39,7 +39,7 @@ class FileWriteAction(Action):
content: str
start: int = 0
end: int = -1
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.WRITE
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
@@ -108,7 +108,7 @@ class FileEditAction(Action):
end: int = -1
# Shared arguments
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.EDIT
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
+2 -2
View File
@@ -2,14 +2,14 @@ from dataclasses import dataclass, field
from typing import Any, ClassVar
from openhands.core.schema import ActionType
from openhands.events.action import Action, ActionSecurityRisk, Thought
from openhands.events.action.action import Action, ActionSecurityRisk
@dataclass
class MCPAction(Action):
name: str
arguments: dict[str, Any] = field(default_factory=dict)
thought: Thought = field(default_factory=Thought)
thought: str = ''
action: str = ActionType.MCP
runnable: ClassVar[bool] = True
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
+2 -21
View File
@@ -1,7 +1,7 @@
from typing import Any
from openhands.core.exceptions import LLMMalformedActionError
from openhands.events.action import Action, ActionSecurityRisk, Thought
from openhands.events.action.action import Action, ActionSecurityRisk
from openhands.events.action.agent import (
AgentDelegateAction,
AgentFinishAction,
@@ -110,8 +110,7 @@ def action_from_dict(action: dict) -> Action:
raise LLMMalformedActionError(
f"'{action['action']=}' is not defined. Available actions: {ACTION_TYPE_TO_CLASS.keys()}"
)
# Work on a copy of args to avoid mutating the caller's dictionary
args = dict(action.get('args', {}))
args = action.get('args', {})
# Remove timestamp from args if present
timestamp = args.pop('timestamp', None)
@@ -125,24 +124,6 @@ def action_from_dict(action: dict) -> Action:
if 'images_urls' in args:
args['image_urls'] = args.pop('images_urls')
# Convert thought arg from legacy formats and capture optional reasoning_content
rc = args.pop('reasoning_content', None)
if 'thought' in args:
t = args['thought']
if isinstance(t, dict):
# Accept either {'text': '...', 'reasoning_content': '...'} or legacy {'thought': '...'}
text = t.get('text') or t.get('thought') or ''
reasoning_content = t.get('reasoning_content') or rc
args['thought'] = Thought(text=text, reasoning_content=reasoning_content)
elif isinstance(t, str):
args['thought'] = Thought(text=t, reasoning_content=rc)
# Inputs to action_from_dict come from wire (JSON→dict), so t will be dict or str.
# Thought instances should not appear here; if they do, they are out-of-band.
# We intentionally do not handle object instances to keep deserialization strict.
elif rc is not None:
# No text thought provided, but reasoning content exists
args['thought'] = Thought(text='', reasoning_content=rc)
# Handle security_risk deserialization
if 'security_risk' in args and args['security_risk'] is not None:
try:
-16
View File
@@ -99,7 +99,6 @@ def _convert_pydantic_to_dict(obj: BaseModel | dict) -> dict:
def event_to_dict(event: 'Event') -> dict:
props = asdict(event)
d = {}
for key in TOP_KEYS:
if hasattr(event, key) and getattr(event, key) is not None:
@@ -127,22 +126,7 @@ def event_to_dict(event: 'Event') -> dict:
# Remove task_completed from serialization when it's None (backward compatibility)
if 'task_completed' in props and props['task_completed'] is None:
props.pop('task_completed')
if 'action' in d:
# Normalize Thought representation strictly at the action args boundary
# Always emit a dict-shaped thought: {"text": str, "reasoning_content": str|null}
t = props.get('thought', None)
if t is not None:
if isinstance(t, dict):
text = t.get('text') or t.get('thought') or ''
rc = t.get('reasoning_content')
props['thought'] = {'text': text, 'reasoning_content': rc}
elif isinstance(t, str):
props['thought'] = {'text': t, 'reasoning_content': None}
else:
# Any other legacy/unknown shape: coerce to safe string
props['thought'] = {'text': str(t), 'reasoning_content': None}
# Handle security_risk for actions - include it in args
if 'security_risk' in props:
props['security_risk'] = props['security_risk'].value
+1 -8
View File
@@ -1,6 +1,4 @@
import json
from dataclasses import asdict as dataclass_asdict
from dataclasses import is_dataclass
from datetime import datetime
from json_repair import repair_json
@@ -14,18 +12,13 @@ from openhands.llm.metrics import Metrics
class OpenHandsJSONEncoder(json.JSONEncoder):
"""Custom JSON encoder that handles datetime, event objects, and nested dataclasses"""
"""Custom JSON encoder that handles datetime and event objects"""
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
# Important: handle Event before generic dataclass handling
if isinstance(obj, Event):
return event_to_dict(obj)
# Fallback: serialize any dataclass (e.g., Thought) to a dict
# Guard against dataclass classes (types) which also return True for is_dataclass
if is_dataclass(obj) and not isinstance(obj, type):
return dataclass_asdict(obj) # type: ignore[arg-type]
if isinstance(obj, Metrics):
return obj.get()
if isinstance(obj, ModelResponse):
+8 -12
View File
@@ -19,7 +19,6 @@ from openhands.events.action import (
IPythonRunCellAction,
MessageAction,
TaskTrackingAction,
Thought,
)
from openhands.events.action.mcp import MCPAction
from openhands.events.action.message import SystemMessageAction
@@ -283,24 +282,21 @@ class ConversationMemory:
)
content = assistant_msg.content or ''
# Update the Thought text with assistant content when present
cur_text = action.thought.text
if cur_text != content:
action.thought.text = (
(cur_text + '\n' + content) if cur_text else content
)
# save content if any, to thought
if action.thought:
if action.thought != content:
action.thought += '\n' + content
else:
action.thought = content
# remove the tool call metadata
if hasattr(action, '_tool_call_metadata'):
delattr(action, '_tool_call_metadata')
action.tool_call_metadata = None
if role not in ('user', 'system', 'assistant', 'tool'):
raise ValueError(f'Invalid role: {role}')
# Only send plain thought text to the LLM
thought_text = action.thought.text
return [
Message(
role=role, # type: ignore[arg-type]
content=[TextContent(text=thought_text)],
content=[TextContent(text=action.thought)],
)
]
elif isinstance(action, MessageAction):
+3 -1
View File
@@ -15,6 +15,8 @@ from openhands.runtime.plugins.requirement import Plugin, PluginRequirement
from openhands.runtime.utils.system import check_port_available
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
@dataclass
class VSCodeRequirement(PluginRequirement):
@@ -37,7 +39,7 @@ class VSCodePlugin(Plugin):
)
return
if username not in ['root', 'openhands']:
if username not in filter(None, [RUNTIME_USERNAME, 'root', 'openhands']):
self.vscode_port = None
self.vscode_connection_token = None
logger.warning(
+3 -1
View File
@@ -20,6 +20,8 @@ from openhands.events.observation.commands import (
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
from openhands.utils.shutdown_listener import should_continue
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
def split_bash_commands(commands: str) -> list[str]:
if not commands.strip():
@@ -193,7 +195,7 @@ class BashSession:
def initialize(self) -> None:
self.server = libtmux.Server()
_shell_command = '/bin/bash'
if self.username in ['root', 'openhands']:
if self.username in list(filter(None, [RUNTIME_USERNAME, 'root', 'openhands'])):
# This starts a non-login (new) shell for the given user
_shell_command = f'su {self.username} -'
+17 -4
View File
@@ -1,3 +1,5 @@
import os
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.plugins import PluginRequirement
@@ -12,6 +14,9 @@ DEFAULT_PYTHON_PREFIX = [
]
DEFAULT_MAIN_MODULE = 'openhands.runtime.action_execution_server'
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
RUNTIME_UID = os.getenv('RUNTIME_UID')
def get_action_execution_server_startup_command(
server_port: int,
@@ -26,7 +31,10 @@ def get_action_execution_server_startup_command(
sandbox_config = app_config.sandbox
logger.debug(f'app_config {vars(app_config)}')
logger.debug(f'sandbox_config {vars(sandbox_config)}')
logger.debug(f'override_user_id {override_user_id}')
logger.debug(f'RUNTIME_USERNAME {RUNTIME_USERNAME}, RUNTIME_UID {RUNTIME_UID}')
logger.debug(
f'override_username {override_username}, override_user_id {override_user_id}'
)
# Plugin args
plugin_args = []
@@ -40,10 +48,15 @@ def get_action_execution_server_startup_command(
'--browsergym-eval-env'
] + sandbox_config.browsergym_eval_env.split(' ')
username = override_username or (
'openhands' if app_config.run_as_openhands else 'root'
username = (
override_username
or RUNTIME_USERNAME
or ('openhands' if app_config.run_as_openhands else 'root')
)
user_id = override_user_id or (1000 if app_config.run_as_openhands else 0)
user_id = (
override_user_id or RUNTIME_UID or (1000 if app_config.run_as_openhands else 0)
)
logger.debug(f'username {username}, user_id {user_id}')
base_cmd = [
*python_prefix,
+1 -6
View File
@@ -6,9 +6,7 @@ import docker
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.action import Action, ActionSecurityRisk
# Delay import to avoid circular import with openhands.runtime package
# from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils import find_available_tcp_port
from openhands.security.analyzer import SecurityAnalyzer
from openhands.security.invariant.client import InvariantClient
from openhands.security.invariant.parser import TraceElement, parse_element
@@ -55,9 +53,6 @@ class InvariantAnalyzer(SecurityAnalyzer):
self.container = all_containers[0]
all_containers[0].start()
else:
# Local import here to avoid circular import during module initialization
from openhands.runtime.utils import find_available_tcp_port
self.api_port = find_available_tcp_port()
self.container = self.docker_client.containers.run(
self.image_name,
+1 -7
View File
@@ -6,7 +6,6 @@ from openhands.events.action import (
ChangeAgentStateAction,
MessageAction,
NullAction,
Thought,
)
from openhands.events.event import EventSource
from openhands.events.observation import (
@@ -54,12 +53,7 @@ def parse_action(trace: list[TraceElement], action: Action) -> list[TraceElement
function = Function(name=action.action, arguments=args)
if thought is not None:
# We assume Thought is a Thought instance here
if isinstance(thought, Thought):
inv_trace.append(Message(role='assistant', content=thought.text))
else:
# If for some reason it's not Thought (shouldn't happen here), emit empty
inv_trace.append(Message(role='assistant', content=''))
inv_trace.append(Message(role='assistant', content=thought))
inv_trace.append(ToolCall(id=next_id, type='function', function=function))
else:
logger.error(f'Unknown action type: {type(action)}')
+21 -30
View File
@@ -76,7 +76,7 @@ def test_agent_finish_action_serialization_deserialization():
'action': 'finish',
'args': {
'outputs': {},
'thought': {'text': '', 'reasoning_content': None},
'thought': '',
'final_thought': '',
},
}
@@ -89,7 +89,7 @@ def test_agent_finish_action_legacy_task_completed_serialization():
'action': 'finish',
'args': {
'outputs': {},
'thought': {'text': '', 'reasoning_content': None},
'thought': '',
'final_thought': 'Task completed',
'task_completed': 'true', # This should be ignored during deserialization
},
@@ -110,7 +110,7 @@ def test_agent_finish_action_legacy_task_completed_serialization():
def test_agent_reject_action_serialization_deserialization():
original_action_dict = {
'action': 'reject',
'args': {'outputs': {}, 'thought': {'text': '', 'reasoning_content': None}},
'args': {'outputs': {}, 'thought': ''},
}
serialization_deserialization(original_action_dict, AgentRejectAction)
@@ -122,7 +122,7 @@ def test_cmd_run_action_serialization_deserialization():
'blocking': False,
'command': 'echo "Hello world"',
'is_input': False,
'thought': {'text': '', 'reasoning_content': None},
'thought': '',
'hidden': False,
'confirmation_state': ActionConfirmationStatus.CONFIRMED,
'is_static': False,
@@ -137,7 +137,7 @@ def test_browse_url_action_serialization_deserialization():
original_action_dict = {
'action': 'browse',
'args': {
'thought': {'text': '', 'reasoning_content': None},
'thought': '',
'url': 'https://www.example.com',
'return_axtree': False,
'security_risk': -1,
@@ -150,7 +150,7 @@ def test_browse_interactive_action_serialization_deserialization():
original_action_dict = {
'action': 'browse_interactive',
'args': {
'thought': {'text': '', 'reasoning_content': None},
'thought': '',
'browser_actions': 'goto("https://www.example.com")',
'browsergym_send_msg_to_user': '',
'return_axtree': False,
@@ -167,7 +167,7 @@ def test_file_read_action_serialization_deserialization():
'path': '/path/to/file.txt',
'start': 0,
'end': -1,
'thought': {'text': 'None', 'reasoning_content': None},
'thought': 'None',
'impl_source': 'default',
'view_range': None,
'security_risk': -1,
@@ -184,7 +184,7 @@ def test_file_write_action_serialization_deserialization():
'content': 'Hello world',
'start': 0,
'end': 1,
'thought': {'text': 'None', 'reasoning_content': None},
'thought': 'None',
'security_risk': -1,
},
}
@@ -204,7 +204,7 @@ def test_file_edit_action_aci_serialization_deserialization():
'content': '',
'start': 1,
'end': -1,
'thought': {'text': 'Replacing text', 'reasoning_content': None},
'thought': 'Replacing text',
'impl_source': 'oh_aci',
'security_risk': -1,
},
@@ -225,7 +225,7 @@ def test_file_edit_action_llm_serialization_deserialization():
'content': 'Updated content',
'start': 1,
'end': 10,
'thought': {'text': 'Updating file content', 'reasoning_content': None},
'thought': 'Updating file content',
'impl_source': 'llm_based_edit',
'security_risk': -1,
},
@@ -239,7 +239,7 @@ def test_cmd_run_action_legacy_serialization():
'args': {
'blocking': False,
'command': 'echo "Hello world"',
'thought': {'text': '', 'reasoning_content': None},
'thought': '',
'hidden': False,
'confirmation_state': ActionConfirmationStatus.CONFIRMED,
'keep_prompt': False, # will be treated as no-op
@@ -259,7 +259,7 @@ def test_cmd_run_action_legacy_serialization():
)
assert event_dict['args']['blocking'] is False
assert event_dict['args']['command'] == 'echo "Hello world"'
assert event_dict['args']['thought'] == {'text': '', 'reasoning_content': None}
assert event_dict['args']['thought'] == ''
assert event_dict['args']['is_input'] is False
@@ -271,7 +271,7 @@ def test_file_llm_based_edit_action_legacy_serialization():
'content': 'dummy content',
'start': 1,
'end': -1,
'thought': {'text': 'Replacing text', 'reasoning_content': None},
'thought': 'Replacing text',
'impl_source': 'oh_aci',
'translated_ipython_code': None,
},
@@ -304,10 +304,7 @@ def test_file_llm_based_edit_action_legacy_serialization():
# Common arguments
assert event_dict['args']['path'] == '/path/to/file.txt'
assert event_dict['args']['impl_source'] == 'oh_aci'
assert event_dict['args']['thought'] == {
'text': 'Replacing text',
'reasoning_content': None,
}
assert event_dict['args']['thought'] == 'Replacing text'
# OH_ACI arguments
assert event_dict['args']['command'] == ''
@@ -366,10 +363,10 @@ def test_file_ohaci_edit_action_legacy_serialization():
# Common arguments
assert event_dict['args']['path'] == '/workspace/game_2048.py'
assert event_dict['args']['impl_source'] == 'oh_aci'
assert event_dict['args']['thought'] == {
'text': "I'll help you create a simple 2048 game in Python. I'll use the str_replace_editor to create the file.",
'reasoning_content': None,
}
assert (
event_dict['args']['thought']
== "I'll help you create a simple 2048 game in Python. I'll use the str_replace_editor to create the file."
)
# OH_ACI arguments
assert event_dict['args']['command'] == 'create'
@@ -389,10 +386,7 @@ def test_agent_microagent_action_serialization_deserialization():
'action': 'recall',
'args': {
'query': 'What is the capital of France?',
'thought': {
'text': 'I need to find information about France',
'reasoning_content': None,
},
'thought': 'I need to find information about France',
'recall_type': 'knowledge',
},
}
@@ -406,7 +400,7 @@ def test_file_read_action_legacy_serialization():
'path': '/workspace/test.txt',
'start': 0,
'end': -1,
'thought': {'text': 'Reading the file contents', 'reasoning_content': None},
'thought': 'Reading the file contents',
'impl_source': 'oh_aci',
'translated_ipython_code': "print(file_editor(**{'command': 'view', 'path': '/workspace/test.txt'}))",
},
@@ -438,10 +432,7 @@ def test_file_read_action_legacy_serialization():
# Common arguments in serialized form
assert event_dict['args']['path'] == '/workspace/test.txt'
assert event_dict['args']['impl_source'] == 'oh_aci'
assert event_dict['args']['thought'] == {
'text': 'Reading the file contents',
'reasoning_content': None,
}
assert event_dict['args']['thought'] == 'Reading the file contents'
# Read-specific arguments in serialized form
assert event_dict['args']['start'] == 0
@@ -1,173 +0,0 @@
import os
import sys
import pytest
# Ensure this repo takes precedence over any installed openhands package
sys.path.insert(
0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
)
from openhands.events.action import (
AgentDelegateAction,
AgentFinishAction,
AgentRejectAction,
ChangeAgentStateAction,
CmdRunAction,
FileEditAction,
FileReadAction,
FileWriteAction,
IPythonRunCellAction,
RecallAction,
TaskTrackingAction,
Thought,
)
from openhands.events.event import RecallType
from openhands.events.serialization.event import event_from_dict, event_to_dict
from openhands.io import json as oh_json
# ---------------------------
# event_to_dict normalization
# ---------------------------
def test_thought_serialization_flatten_with_reasoning():
a = CmdRunAction(command='echo 1', thought=Thought(text='t', reasoning_content='r'))
d = event_to_dict(a)
assert d['action'] == a.action
assert 'args' in d
assert isinstance(d['args']['thought'], dict)
assert d['args']['thought']['text'] == 't'
assert d['args']['thought']['reasoning_content'] == 'r'
# Round-trip back
a2 = event_from_dict(d)
assert isinstance(a2.thought, Thought)
assert a2.thought.text == 't'
assert a2.thought.reasoning_content == 'r'
# ---------------------------
# action_from_dict handling
# ---------------------------
def test_thought_deserialization_from_string_plus_rc():
d = {
'action': 'run',
'args': {'command': 'echo 1', 'thought': 'hello', 'reasoning_content': 'why'},
}
a = event_from_dict(d)
assert isinstance(a.thought, Thought)
assert a.thought.text == 'hello'
assert a.thought.reasoning_content == 'why'
def test_thought_deserialization_from_dict_text_key():
d = {
'action': 'run',
'args': {
'command': 'echo 1',
'thought': {'text': 'hi', 'reasoning_content': 'rc'},
},
}
a = event_from_dict(d)
assert isinstance(a.thought, Thought)
assert a.thought.text == 'hi'
assert a.thought.reasoning_content == 'rc'
def test_thought_deserialization_from_dict_legacy_thought_key():
d = {
'action': 'run',
'args': {'command': 'echo 1', 'thought': {'thought': 'legacy'}},
}
a = event_from_dict(d)
assert isinstance(a.thought, Thought)
assert a.thought.text == 'legacy'
assert a.thought.reasoning_content is None
def test_thought_deserialization_without_thought_but_with_top_level_rc():
d = {
'action': 'run',
'args': {'command': 'echo 1', 'reasoning_content': 'only-rc'},
}
a = event_from_dict(d)
assert isinstance(a.thought, Thought)
assert a.thought.text == ''
assert a.thought.reasoning_content == 'only-rc'
def test_thought_backwards_compat_direct_init_with_str():
# Direct construction with a string should still work; serializer coerces to dict on wire
a = CmdRunAction(command='echo 1', thought='plain') # type: ignore[arg-type]
d = event_to_dict(a)
assert d['args']['thought'] == {'text': 'plain', 'reasoning_content': None}
# When it comes back from wire, it becomes Thought
a2 = event_from_dict(d)
assert isinstance(a2.thought, Thought)
assert a2.thought.text == 'plain'
# ---------------------------
# Round-trip across action types
# ---------------------------
@pytest.mark.parametrize(
'action',
[
CmdRunAction(
command='echo 1', thought=Thought(text='t', reasoning_content='r')
),
IPythonRunCellAction(
code='x=1', thought=Thought(text='t', reasoning_content='r')
),
FileReadAction(path='/tmp/a', thought=Thought(text='t', reasoning_content='r')),
FileWriteAction(
path='/tmp/a', content='c', thought=Thought(text='t', reasoning_content='r')
),
FileEditAction(
path='/tmp/a',
command='view',
thought=Thought(text='t', reasoning_content='r'),
),
AgentFinishAction(
final_thought='done', thought=Thought(text='t', reasoning_content='r')
),
AgentRejectAction(thought=Thought(text='t', reasoning_content='r')),
AgentDelegateAction(
agent='helper', inputs={}, thought=Thought(text='t', reasoning_content='r')
),
ChangeAgentStateAction(
agent_state='running', thought=Thought(text='t', reasoning_content='r')
),
RecallAction(
recall_type=RecallType.WORKSPACE_CONTEXT,
thought=Thought(text='t', reasoning_content='r'),
),
TaskTrackingAction(
task_list=[{'id': 1, 'title': 'a'}],
thought=Thought(text='t', reasoning_content='r'),
),
],
)
def test_thought_serializes_round_trip(action):
d = event_to_dict(action)
assert d['action'] == action.action
assert 'args' in d
assert isinstance(d['args'].get('thought'), dict)
assert d['args']['thought']['text'] == 't'
assert d['args']['thought']['reasoning_content'] == 'r'
# json encoder should handle dicts produced by serializer
s = oh_json.dumps(d)
assert isinstance(s, str) and s
# round-trip back to object
a2 = event_from_dict(d)
assert isinstance(a2.thought, Thought)
assert a2.thought.text == 't'
assert a2.thought.reasoning_content == 'r'
@@ -1,45 +0,0 @@
import os
import sys
from unittest.mock import MagicMock
from openhands.core.config.agent_config import AgentConfig
# Ensure this repo takes precedence over any installed openhands package
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
from openhands.events.action import Thought
from openhands.events.action.agent import AgentFinishAction
from openhands.events.action.message import MessageAction
from openhands.memory.conversation_memory import ConversationMemory
from openhands.utils.prompt import PromptManager
def test_llm_receives_only_thought_text():
# Setup
agent_config = AgentConfig()
prompt_manager = MagicMock(spec=PromptManager)
prompt_manager.get_system_message.return_value = 'System message'
cm = ConversationMemory(agent_config, prompt_manager)
user_msg = MessageAction(content='hi')
finish = AgentFinishAction(
final_thought='done',
thought=Thought(text='visible', reasoning_content='secret'),
)
messages = cm.process_events(
condensed_history=[finish],
initial_user_action=user_msg,
max_message_chars=None,
vision_is_active=False,
)
# Find the assistant message produced from AgentFinishAction
assistant_texts = []
for m in messages:
if m.role == 'assistant':
for c in m.content:
if hasattr(c, 'text'):
assistant_texts.append(c.text)
combined = '\n'.join(assistant_texts)
assert 'visible' in combined
assert 'secret' not in combined