mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
68 Commits
self-hoste
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1c3a2088 | ||
|
|
b49a7164f2 | ||
|
|
9225a6026b | ||
|
|
69c900079e | ||
|
|
f55ed151f1 | ||
|
|
e19f14e255 | ||
|
|
0aaef2927f | ||
|
|
212fb76535 | ||
|
|
0fe0754b23 | ||
|
|
4f503a2f6e | ||
|
|
8a26dc5e03 | ||
|
|
52360541fe | ||
|
|
4c9898dd26 | ||
|
|
4d90079a0c | ||
|
|
483abc67d0 | ||
|
|
d4fd69dc6a | ||
|
|
1d13e97098 | ||
|
|
d686e37a41 | ||
|
|
0a6d8cbff9 | ||
|
|
e469331cf3 | ||
|
|
16bfc517e5 | ||
|
|
41e4fac615 | ||
|
|
a5aa03b7a5 | ||
|
|
70d26b711b | ||
|
|
518c817fdc | ||
|
|
34462b1035 | ||
|
|
1bc2dc36ac | ||
|
|
c546547644 | ||
|
|
04ed9b5e3c | ||
|
|
5786595ccf | ||
|
|
49ee9d9d57 | ||
|
|
6a878a8001 | ||
|
|
cf9794cd81 | ||
|
|
00d08cbf9a | ||
|
|
a10d678386 | ||
|
|
8d9c095d1f | ||
|
|
a00d2a4c65 | ||
|
|
196c304e2a | ||
|
|
d6cdfd0c04 | ||
|
|
a9749d6822 | ||
|
|
a0476fde32 | ||
|
|
097c443c80 | ||
|
|
19930c4cd6 | ||
|
|
21456a733a | ||
|
|
e3de03d7bc | ||
|
|
2872d105aa | ||
|
|
40184da146 | ||
|
|
e184140278 | ||
|
|
69badd21a7 | ||
|
|
88ce70fdc0 | ||
|
|
0ccf802e58 | ||
|
|
381029026a | ||
|
|
cf51cee65c | ||
|
|
5bb82c811f | ||
|
|
f4427fb623 | ||
|
|
f1a51f723e | ||
|
|
f0ab8ae7e3 | ||
|
|
4f58f50073 | ||
|
|
cf3b9137e0 | ||
|
|
1d464a59f9 | ||
|
|
511a5d396e | ||
|
|
0336a988e6 | ||
|
|
38864093f0 | ||
|
|
36419942a1 | ||
|
|
d5b26226f2 | ||
|
|
b01da8dfc0 | ||
|
|
b037243149 | ||
|
|
cddd282e5c |
13
.openhands/TASKS.md
Normal file
13
.openhands/TASKS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 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: "Task completed successfully",
|
||||
thought: { text: "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: "I need more information to proceed.",
|
||||
thought: { text: "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: "Task completed successfully",
|
||||
thought: { text: "Task completed successfully" },
|
||||
},
|
||||
message: "Task completed successfully",
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("Messages", () => {
|
||||
args: {
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
thought: "",
|
||||
thought: { text: "" },
|
||||
wait_for_response: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -67,16 +67,14 @@ const getMcpActionContent = (event: MCPAction): string => {
|
||||
const name = event.args.name || "";
|
||||
const args = event.args.arguments || {};
|
||||
let details = `**MCP Tool Call:** ${name}\n\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\`\`\``;
|
||||
details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(args, null, 2)}\n\`\`\``;
|
||||
return details;
|
||||
};
|
||||
|
||||
const getThinkActionContent = (event: ThinkAction): string =>
|
||||
event.args.thought;
|
||||
const getThinkActionContent = (event: ThinkAction): string => {
|
||||
const t = event.args.thought;
|
||||
return t.reasoning_content ? `${t.reasoning_content}\n\n${t.text}` : t.text;
|
||||
};
|
||||
|
||||
const getFinishActionContent = (event: FinishAction): string =>
|
||||
event.args.final_thought.trim();
|
||||
|
||||
@@ -33,7 +33,20 @@ import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
|
||||
const hasThoughtProperty = (
|
||||
obj: Record<string, unknown>,
|
||||
): obj is { thought: string } => "thought" in obj && !!obj.thought;
|
||||
): 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)
|
||||
);
|
||||
};
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsAction | OpenHandsObservation;
|
||||
@@ -121,11 +134,20 @@ export function EventMessage({
|
||||
if (hasThoughtProperty(event.args) && event.action !== "think") {
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={event.args.thought}
|
||||
actions={actions}
|
||||
/>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
@@ -148,6 +170,13 @@ 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}
|
||||
@@ -247,7 +276,21 @@ export function EventMessage({
|
||||
{isOpenHandsAction(event) &&
|
||||
hasThoughtProperty(event.args) &&
|
||||
event.action !== "think" && (
|
||||
<ChatMessage type="agent" message={event.args.thought} />
|
||||
<>
|
||||
{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 || ""}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<GenericEventMessage
|
||||
|
||||
@@ -816,6 +816,7 @@ 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",
|
||||
|
||||
@@ -13055,6 +13055,22 @@
|
||||
"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": "ランタイムの開始を待機中...",
|
||||
|
||||
@@ -29,7 +29,7 @@ export const generateAssistantMessageAction = (
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
thought: message,
|
||||
thought: { text: message },
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
wait_for_response: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { OpenHandsActionEvent } from "./base";
|
||||
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
|
||||
import { Thought } from "./thought";
|
||||
|
||||
export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
|
||||
source: "user";
|
||||
@@ -26,7 +27,7 @@ export interface CommandAction extends OpenHandsActionEvent<"run"> {
|
||||
command: string;
|
||||
security_risk: ActionSecurityRisk;
|
||||
confirmation_state: "confirmed" | "rejected" | "awaiting_confirmation";
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
hidden?: boolean;
|
||||
};
|
||||
}
|
||||
@@ -35,7 +36,7 @@ export interface AssistantMessageAction
|
||||
extends OpenHandsActionEvent<"message"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
image_urls: string[] | null;
|
||||
file_urls: string[];
|
||||
wait_for_response: boolean;
|
||||
@@ -49,14 +50,14 @@ export interface IPythonAction extends OpenHandsActionEvent<"run_ipython"> {
|
||||
security_risk: ActionSecurityRisk;
|
||||
confirmation_state: "confirmed" | "rejected" | "awaiting_confirmation";
|
||||
kernel_init_code: string;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThinkAction extends OpenHandsActionEvent<"think"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ export interface FinishAction extends OpenHandsActionEvent<"finish"> {
|
||||
args: {
|
||||
final_thought: string;
|
||||
outputs: Record<string, unknown>;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ export interface DelegateAction extends OpenHandsActionEvent<"delegate"> {
|
||||
args: {
|
||||
agent: "BrowsingAgent";
|
||||
inputs: Record<string, string>;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ export interface BrowseAction extends OpenHandsActionEvent<"browse"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
url: string;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +94,7 @@ export interface BrowseInteractiveAction
|
||||
timeout: number;
|
||||
args: {
|
||||
browser_actions: string;
|
||||
thought: string | null;
|
||||
thought: Thought | null;
|
||||
browsergym_send_msg_to_user: string;
|
||||
};
|
||||
}
|
||||
@@ -102,7 +103,7 @@ export interface FileReadAction extends OpenHandsActionEvent<"read"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
path: string;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
security_risk: ActionSecurityRisk | null;
|
||||
impl_source?: string;
|
||||
view_range?: number[] | null;
|
||||
@@ -114,7 +115,7 @@ export interface FileWriteAction extends OpenHandsActionEvent<"write"> {
|
||||
args: {
|
||||
path: string;
|
||||
content: string;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> {
|
||||
content?: string;
|
||||
start?: number;
|
||||
end?: number;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
security_risk: ActionSecurityRisk | null;
|
||||
impl_source?: string;
|
||||
};
|
||||
@@ -140,7 +141,7 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> {
|
||||
export interface RejectAction extends OpenHandsActionEvent<"reject"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,7 +150,7 @@ export interface RecallAction extends OpenHandsActionEvent<"recall"> {
|
||||
args: {
|
||||
recall_type: "workspace_context" | "knowledge";
|
||||
query: string;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +159,7 @@ export interface MCPAction extends OpenHandsActionEvent<"call_tool_mcp"> {
|
||||
args: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
thought?: string;
|
||||
thought?: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,7 +174,7 @@ export interface TaskTrackingAction
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
}>;
|
||||
thought: string;
|
||||
thought: Thought;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
4
frontend/src/types/core/thought.ts
Normal file
4
frontend/src/types/core/thought.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Thought {
|
||||
text: string;
|
||||
reasoning_content?: string | null;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
BrowseInteractiveAction,
|
||||
Thought,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,9 +63,10 @@ class BrowsingActionParserMessage(ActionParser):
|
||||
|
||||
def parse(self, action_str: str) -> Action:
|
||||
msg = f'send_msg_to_user("""{action_str}""")'
|
||||
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions=msg,
|
||||
thought=action_str,
|
||||
thought=Thought(text=action_str),
|
||||
browsergym_send_msg_to_user=action_str,
|
||||
)
|
||||
|
||||
@@ -121,6 +123,6 @@ class BrowsingActionParserBrowseInteractive(ActionParser):
|
||||
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions=browser_actions,
|
||||
thought=thought,
|
||||
thought=Thought(text=thought),
|
||||
browsergym_send_msg_to_user=msg_content,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ from openhands.events.action import (
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
TaskTrackingAction,
|
||||
Thought,
|
||||
)
|
||||
from openhands.events.action.agent import CondensationRequestAction
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
@@ -46,13 +47,24 @@ from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm.tool_names import TASK_TRACKER_TOOL_NAME
|
||||
|
||||
|
||||
def combine_thought(action: Action, thought: str) -> Action:
|
||||
def combine_thought(
|
||||
action: Action, thought: str, reasoning_content: str | None = None
|
||||
) -> Action:
|
||||
if not hasattr(action, 'thought'):
|
||||
return action
|
||||
if thought and action.thought:
|
||||
action.thought = f'{thought}\n{action.thought}'
|
||||
elif thought:
|
||||
action.thought = thought
|
||||
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
|
||||
return action
|
||||
|
||||
|
||||
@@ -80,12 +92,26 @@ 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):
|
||||
@@ -231,7 +257,9 @@ def response_to_actions(
|
||||
# AgentThinkAction
|
||||
# ================================================
|
||||
elif tool_call.function.name == ThinkTool['function']['name']:
|
||||
action = AgentThinkAction(thought=arguments.get('thought', ''))
|
||||
action = AgentThinkAction(
|
||||
thought=Thought(text=arguments.get('thought', ''))
|
||||
)
|
||||
|
||||
# ================================================
|
||||
# CondensationRequestAction
|
||||
@@ -310,7 +338,7 @@ def response_to_actions(
|
||||
|
||||
# We only add thought to the first action
|
||||
if i == 0:
|
||||
action = combine_thought(action, thought)
|
||||
action = combine_thought(action, thought, reasoning_content)
|
||||
# Add metadata for tool calling
|
||||
action.tool_call_metadata = ToolCallMetadata(
|
||||
tool_call_id=tool_call.id,
|
||||
|
||||
@@ -12,6 +12,7 @@ from openhands.events.action import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
MessageAction,
|
||||
Thought,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
@@ -91,7 +92,7 @@ class DummyAgent(Agent):
|
||||
},
|
||||
{
|
||||
'action': AgentFinishAction(
|
||||
outputs={}, thought='Task completed', action='finish'
|
||||
outputs={}, thought=Thought(text='Task completed'), action='finish'
|
||||
),
|
||||
'observations': [AgentStateChangedObservation('', AgentState.FINISHED)],
|
||||
},
|
||||
|
||||
@@ -42,12 +42,23 @@ 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):
|
||||
@@ -89,7 +100,7 @@ def response_to_actions(
|
||||
|
||||
# We only add thought to the first action
|
||||
if i == 0:
|
||||
action = combine_thought(action, thought)
|
||||
action = combine_thought(action, thought, reasoning_content)
|
||||
# Add metadata for tool calling
|
||||
action.tool_call_metadata = ToolCallMetadata(
|
||||
tool_call_id=tool_call.id,
|
||||
|
||||
@@ -36,6 +36,7 @@ from openhands.events.action import (
|
||||
FileReadAction,
|
||||
MCPAction,
|
||||
MessageAction,
|
||||
Thought,
|
||||
)
|
||||
from openhands.events.event import FileReadSource
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
@@ -117,12 +118,23 @@ 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):
|
||||
@@ -161,7 +173,9 @@ def response_to_actions(
|
||||
# AgentThinkAction
|
||||
# ================================================
|
||||
elif tool_call.function.name == ThinkTool['function']['name']:
|
||||
action = AgentThinkAction(thought=arguments.get('thought', ''))
|
||||
action = AgentThinkAction(
|
||||
thought=Thought(text=arguments.get('thought', ''))
|
||||
)
|
||||
|
||||
# ================================================
|
||||
# GrepTool (file content search)
|
||||
@@ -210,7 +224,7 @@ def response_to_actions(
|
||||
|
||||
# We only add thought to the first action
|
||||
if i == 0:
|
||||
action = combine_thought(action, thought)
|
||||
action = combine_thought(action, thought, reasoning_content)
|
||||
# Add metadata for tool calling
|
||||
action.tool_call_metadata = ToolCallMetadata(
|
||||
tool_call_id=tool_call.id,
|
||||
|
||||
@@ -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(event.thought)
|
||||
display_thought_if_new(str(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(event.thought)
|
||||
display_thought_if_new(str(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(event.thought)
|
||||
display_thought_if_new(str(event.thought))
|
||||
|
||||
# Format the command and task list for display
|
||||
display_text = f'Command: {event.command}'
|
||||
|
||||
@@ -659,7 +659,8 @@ class AgentController:
|
||||
new_state in (AgentState.USER_CONFIRMED, AgentState.USER_REJECTED)
|
||||
):
|
||||
if hasattr(self._pending_action, 'thought'):
|
||||
self._pending_action.thought = '' # type: ignore[union-attr]
|
||||
# Clear the thought text for confirmed/rejected actions
|
||||
self._pending_action.thought.text = '' # type: ignore[attr-defined]
|
||||
if new_state == AgentState.USER_CONFIRMED:
|
||||
confirmation_state = ActionConfirmationStatus.CONFIRMED
|
||||
else:
|
||||
|
||||
@@ -2,6 +2,7 @@ from openhands.events.action.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
ActionSecurityRisk,
|
||||
Thought,
|
||||
)
|
||||
from openhands.events.action.agent import (
|
||||
AgentDelegateAction,
|
||||
@@ -44,5 +45,6 @@ __all__ = [
|
||||
'RecallAction',
|
||||
'MCPAction',
|
||||
'TaskTrackingAction',
|
||||
'Thought',
|
||||
'ActionSecurityRisk',
|
||||
]
|
||||
|
||||
@@ -21,3 +21,39 @@ 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]
|
||||
|
||||
@@ -2,7 +2,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action import Action, Thought
|
||||
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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.FINISH
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
if self.thought != '':
|
||||
return self.thought
|
||||
if self.thought and str(self.thought) != '':
|
||||
return str(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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.THINK
|
||||
|
||||
@property
|
||||
@@ -62,7 +62,7 @@ class AgentThinkAction(Action):
|
||||
@dataclass
|
||||
class AgentRejectAction(Action):
|
||||
outputs: dict = field(default_factory=dict)
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.REJECT
|
||||
|
||||
@property
|
||||
@@ -77,7 +77,7 @@ class AgentRejectAction(Action):
|
||||
class AgentDelegateAction(Action):
|
||||
agent: str
|
||||
inputs: dict
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.DELEGATE
|
||||
|
||||
@property
|
||||
@@ -91,7 +91,7 @@ class RecallAction(Action):
|
||||
|
||||
recall_type: RecallType
|
||||
query: str = ''
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.TASK_TRACKING
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
from openhands.events.action import Action, ActionSecurityRisk, Thought
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrowseURLAction(Action):
|
||||
url: str
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
browsergym_send_msg_to_user: str = ''
|
||||
action: str = ActionType.BROWSE_INTERACTIVE
|
||||
runnable: ClassVar[bool] = True
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
@@ -6,6 +6,7 @@ from openhands.events.action.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
ActionSecurityRisk,
|
||||
Thought,
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +16,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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
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
|
||||
@@ -42,7 +43,7 @@ class CmdRunAction(Action):
|
||||
@dataclass
|
||||
class IPythonRunCellAction(Action):
|
||||
code: str
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
include_extra: bool = (
|
||||
True # whether to include CWD & Python interpreter in the output
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import ClassVar
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
from openhands.events.action import Action, ActionSecurityRisk, Thought
|
||||
from openhands.events.event import FileEditSource, FileReadSource
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class FileReadAction(Action):
|
||||
path: str
|
||||
start: int = 0
|
||||
end: int = -1
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
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: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.EDIT
|
||||
runnable: ClassVar[bool] = True
|
||||
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
|
||||
|
||||
@@ -2,14 +2,14 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
from openhands.events.action import Action, ActionSecurityRisk, Thought
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPAction(Action):
|
||||
name: str
|
||||
arguments: dict[str, Any] = field(default_factory=dict)
|
||||
thought: str = ''
|
||||
thought: Thought = field(default_factory=Thought)
|
||||
action: str = ActionType.MCP
|
||||
runnable: ClassVar[bool] = True
|
||||
security_risk: ActionSecurityRisk = ActionSecurityRisk.UNKNOWN
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.exceptions import LLMMalformedActionError
|
||||
from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
from openhands.events.action import Action, ActionSecurityRisk, Thought
|
||||
from openhands.events.action.agent import (
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
@@ -110,7 +110,8 @@ def action_from_dict(action: dict) -> Action:
|
||||
raise LLMMalformedActionError(
|
||||
f"'{action['action']=}' is not defined. Available actions: {ACTION_TYPE_TO_CLASS.keys()}"
|
||||
)
|
||||
args = action.get('args', {})
|
||||
# Work on a copy of args to avoid mutating the caller's dictionary
|
||||
args = dict(action.get('args', {}))
|
||||
# Remove timestamp from args if present
|
||||
timestamp = args.pop('timestamp', None)
|
||||
|
||||
@@ -124,6 +125,24 @@ 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:
|
||||
|
||||
@@ -99,6 +99,7 @@ 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:
|
||||
@@ -126,7 +127,22 @@ 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,4 +1,6 @@
|
||||
import json
|
||||
from dataclasses import asdict as dataclass_asdict
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from json_repair import repair_json
|
||||
@@ -12,13 +14,18 @@ from openhands.llm.metrics import Metrics
|
||||
|
||||
|
||||
class OpenHandsJSONEncoder(json.JSONEncoder):
|
||||
"""Custom JSON encoder that handles datetime and event objects"""
|
||||
"""Custom JSON encoder that handles datetime, event objects, and nested dataclasses"""
|
||||
|
||||
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):
|
||||
|
||||
@@ -19,6 +19,7 @@ from openhands.events.action import (
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
TaskTrackingAction,
|
||||
Thought,
|
||||
)
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
@@ -282,21 +283,24 @@ class ConversationMemory:
|
||||
)
|
||||
content = assistant_msg.content or ''
|
||||
|
||||
# save content if any, to thought
|
||||
if action.thought:
|
||||
if action.thought != content:
|
||||
action.thought += '\n' + content
|
||||
else:
|
||||
action.thought = content
|
||||
# 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
|
||||
)
|
||||
|
||||
# remove the tool call metadata
|
||||
action.tool_call_metadata = None
|
||||
if hasattr(action, '_tool_call_metadata'):
|
||||
delattr(action, '_tool_call_metadata')
|
||||
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=action.thought)],
|
||||
content=[TextContent(text=thought_text)],
|
||||
)
|
||||
]
|
||||
elif isinstance(action, MessageAction):
|
||||
|
||||
@@ -6,7 +6,9 @@ import docker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action, ActionSecurityRisk
|
||||
from openhands.runtime.utils import find_available_tcp_port
|
||||
|
||||
# Delay import to avoid circular import with openhands.runtime package
|
||||
# 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
|
||||
@@ -53,6 +55,9 @@ 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,
|
||||
|
||||
@@ -6,6 +6,7 @@ from openhands.events.action import (
|
||||
ChangeAgentStateAction,
|
||||
MessageAction,
|
||||
NullAction,
|
||||
Thought,
|
||||
)
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.observation import (
|
||||
@@ -53,7 +54,12 @@ def parse_action(trace: list[TraceElement], action: Action) -> list[TraceElement
|
||||
|
||||
function = Function(name=action.action, arguments=args)
|
||||
if thought is not None:
|
||||
inv_trace.append(Message(role='assistant', content=thought))
|
||||
# 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(ToolCall(id=next_id, type='function', function=function))
|
||||
else:
|
||||
logger.error(f'Unknown action type: {type(action)}')
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_agent_finish_action_serialization_deserialization():
|
||||
'action': 'finish',
|
||||
'args': {
|
||||
'outputs': {},
|
||||
'thought': '',
|
||||
'thought': {'text': '', 'reasoning_content': None},
|
||||
'final_thought': '',
|
||||
},
|
||||
}
|
||||
@@ -89,7 +89,7 @@ def test_agent_finish_action_legacy_task_completed_serialization():
|
||||
'action': 'finish',
|
||||
'args': {
|
||||
'outputs': {},
|
||||
'thought': '',
|
||||
'thought': {'text': '', 'reasoning_content': None},
|
||||
'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': ''},
|
||||
'args': {'outputs': {}, 'thought': {'text': '', 'reasoning_content': None}},
|
||||
}
|
||||
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': '',
|
||||
'thought': {'text': '', 'reasoning_content': None},
|
||||
'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': '',
|
||||
'thought': {'text': '', 'reasoning_content': None},
|
||||
'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': '',
|
||||
'thought': {'text': '', 'reasoning_content': None},
|
||||
'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': 'None',
|
||||
'thought': {'text': 'None', 'reasoning_content': 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': 'None',
|
||||
'thought': {'text': 'None', 'reasoning_content': None},
|
||||
'security_risk': -1,
|
||||
},
|
||||
}
|
||||
@@ -204,7 +204,7 @@ def test_file_edit_action_aci_serialization_deserialization():
|
||||
'content': '',
|
||||
'start': 1,
|
||||
'end': -1,
|
||||
'thought': 'Replacing text',
|
||||
'thought': {'text': 'Replacing text', 'reasoning_content': None},
|
||||
'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': 'Updating file content',
|
||||
'thought': {'text': 'Updating file content', 'reasoning_content': None},
|
||||
'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': '',
|
||||
'thought': {'text': '', 'reasoning_content': None},
|
||||
'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'] == ''
|
||||
assert event_dict['args']['thought'] == {'text': '', 'reasoning_content': None}
|
||||
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': 'Replacing text',
|
||||
'thought': {'text': 'Replacing text', 'reasoning_content': None},
|
||||
'impl_source': 'oh_aci',
|
||||
'translated_ipython_code': None,
|
||||
},
|
||||
@@ -304,7 +304,10 @@ 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'] == 'Replacing text'
|
||||
assert event_dict['args']['thought'] == {
|
||||
'text': 'Replacing text',
|
||||
'reasoning_content': None,
|
||||
}
|
||||
|
||||
# OH_ACI arguments
|
||||
assert event_dict['args']['command'] == ''
|
||||
@@ -363,10 +366,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']
|
||||
== "I'll help you create a simple 2048 game in Python. I'll use the str_replace_editor to create the file."
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
# OH_ACI arguments
|
||||
assert event_dict['args']['command'] == 'create'
|
||||
@@ -386,7 +389,10 @@ def test_agent_microagent_action_serialization_deserialization():
|
||||
'action': 'recall',
|
||||
'args': {
|
||||
'query': 'What is the capital of France?',
|
||||
'thought': 'I need to find information about France',
|
||||
'thought': {
|
||||
'text': 'I need to find information about France',
|
||||
'reasoning_content': None,
|
||||
},
|
||||
'recall_type': 'knowledge',
|
||||
},
|
||||
}
|
||||
@@ -400,7 +406,7 @@ def test_file_read_action_legacy_serialization():
|
||||
'path': '/workspace/test.txt',
|
||||
'start': 0,
|
||||
'end': -1,
|
||||
'thought': 'Reading the file contents',
|
||||
'thought': {'text': 'Reading the file contents', 'reasoning_content': None},
|
||||
'impl_source': 'oh_aci',
|
||||
'translated_ipython_code': "print(file_editor(**{'command': 'view', 'path': '/workspace/test.txt'}))",
|
||||
},
|
||||
@@ -432,7 +438,10 @@ 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'] == 'Reading the file contents'
|
||||
assert event_dict['args']['thought'] == {
|
||||
'text': 'Reading the file contents',
|
||||
'reasoning_content': None,
|
||||
}
|
||||
|
||||
# Read-specific arguments in serialized form
|
||||
assert event_dict['args']['start'] == 0
|
||||
|
||||
173
tests/unit/events/test_thought_serialization.py
Normal file
173
tests/unit/events/test_thought_serialization.py
Normal file
@@ -0,0 +1,173 @@
|
||||
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'
|
||||
45
tests/unit/memory/test_llm_input_thought_only.py
Normal file
45
tests/unit/memory/test_llm_input_thought_only.py
Normal file
@@ -0,0 +1,45 @@
|
||||
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
|
||||
Reference in New Issue
Block a user