Compare commits

...

68 Commits

Author SHA1 Message Date
enyst
ff1c3a2088 Conversation memory and invariant parser: assume Thought only; remove legacy string handling
- conversation_memory: treat action.thought strictly as Thought; drop str coercion
- invariant/parser: import Thought and use .text only; no dict/str fallbacks

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-06 02:17:02 +00:00
enyst
b49a7164f2 Fix CI: normalize Thought handling; break import cycle; invariant parser robust text extraction
Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-06 01:34:35 +00:00
enyst
9225a6026b Complete Thought object serialization support
- Add Thought class to action module exports for test compatibility
- Enhance action deserialization to handle legacy thought formats and reasoning_content
- Add JSON serialization support for Thought objects in custom encoder
- Fix all thought serialization tests including legacy format compatibility
- All 110 event tests now pass
2025-09-03 03:05:31 +00:00
enyst
69c900079e Fix action serialization tests by handling Thought object deserialization
- Add proper handling for thought field conversion from dict to Thought object in action_from_dict
- Add thought serialization logic in event_to_dict to ensure consistent dict format
- Fix shallow copy issue in action_from_dict to avoid mutating original data
- All action serialization tests now pass including legacy serialization tests
2025-09-03 02:58:33 +00:00
enyst
f55ed151f1 Resolve merge conflict in openhands/cli/tui.py
- Use display_thought_if_new(str(event.thought)) to combine:
  - Structured thought support from feature/structured-thought branch
  - Thought deduplication functionality from main branch
- The str() conversion handles the new Thought dataclass properly

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-03 01:29:50 +00:00
openhands
e19f14e255 chore: add verification tasks for PR#10432 items 11 and 12
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-30 01:45:17 +00:00
Engel Nyst
0aaef2927f Merge branch 'main' into feature/structured-thought 2025-08-27 15:57:31 +02:00
Engel Nyst
212fb76535 Merge branch 'main' into feature/structured-thought 2025-08-27 05:28:29 +02:00
OpenHands Bot
0fe0754b23 🤖 Auto-fix Python linting issues 2025-08-27 03:12:44 +00:00
Engel Nyst
4f503a2f6e fix over-done code 2025-08-27 00:39:45 +02:00
Engel Nyst
8a26dc5e03 Merge branch 'main' of github.com:All-Hands-AI/OpenHands into feature/structured-thought 2025-08-25 23:06:57 +02:00
Engel Nyst
52360541fe Update .openhands/microagents/repo.md 2025-08-24 03:20:05 +02:00
enyst
4c9898dd26 docs(repo.md): remove temporary Thought serialization notes (PR #10432)\n\nCo-authored-by: openhands <openhands@all-hands.dev>\n 2025-08-24 01:19:29 +00:00
enyst
4d90079a0c tests(events): relocate and dedupe Thought serialization tests under events/\n\nCo-authored-by: openhands <openhands@all-hands.dev>\n 2025-08-24 01:14:20 +00:00
enyst
483abc67d0 tests: expand Thought serialization coverage; keep tests under events/ + unit roots where appropriate\n\nCo-authored-by: openhands <openhands@all-hands.dev>\n 2025-08-24 00:55:51 +00:00
enyst
d4fd69dc6a Refine Thought serialization: remove unreachable Thought-instance path; normalize dict-shaped thought in event_to_dict args\n\nCo-authored-by: openhands <openhands@all-hands.dev>\n 2025-08-24 00:42:12 +00:00
Engel Nyst
1d13e97098 Update openhands/events/action/action.py 2025-08-24 02:07:29 +02:00
Engel Nyst
d686e37a41 Update openhands/core/config/utils.py 2025-08-24 02:06:16 +02:00
enyst
0a6d8cbff9 Fix inconsistent thought handling in MCP action content
Remove thought handling from getMcpActionContent() to avoid duplicate display.
The event-message.tsx component already handles structured thoughts uniformly
for all actions except 'think', so MCP actions were showing thoughts twice:
- Once in the action content (via get-action-content.ts)
- Once separately in the UI (via event-message.tsx)

Keep thought handling in getThinkActionContent() since think actions are
specifically excluded from the UI's separate thought handling.

This ensures consistent behavior across all action types and eliminates
confusing duplicate content display.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-23 23:24:49 +00:00
enyst
e469331cf3 Fix relative imports to use absolute imports
Change 'from .action import' to 'from openhands.events.action import'
to follow project conventions for consistent absolute imports.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-23 23:08:08 +00:00
enyst
16bfc517e5 Clean up ugly double imports for Thought class
Replace 'from openhands.events.action.action import Thought' with cleaner imports:
- Use 'from openhands.events.action import Thought' for external modules
- Use 'from .action import Thought' for modules within the same directory

This improves code readability and follows Python import best practices.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-23 23:01:16 +00:00
enyst
41e4fac615 Remove unnecessary i18n bundle bloat
- Remove import of entire translation.json file
- Remove redundant backend configuration and resource pre-loading
- Restore simple i18next-http-backend dynamic loading
- Reduces bundle size by removing 13,000+ lines from build
- Maintains proper on-demand translation loading
2025-08-23 22:50:53 +00:00
enyst
a5aa03b7a5 Address Xingyaoww review comments and resolve merge conflicts 2025-08-23 22:37:43 +00:00
Engel Nyst
70d26b711b Merge branch 'main' into feature/structured-thought 2025-08-23 23:58:48 +02:00
Xingyao Wang
518c817fdc Merge branch 'main' into feature/structured-thought 2025-08-21 22:36:17 -04:00
Engel Nyst
34462b1035 thought: canonical wire format and boundary-only handling; send Thought.text only to LLM; tests; revert security analyzer to main
Co-authored-by: OpenHands-GPT-5 openhands@all-hands.dev
2025-08-22 01:32:00 +00:00
Engel Nyst
1bc2dc36ac thought: canonicalize Thought wire format; boundary-only normalization; scope PR
- Always serialize thought as {text, reasoning_content} (dict)
- Keep deserialization backward-compatible (string or dict -> Thought)
- Stop mutating/normalizing Thought in ConversationMemory; only use .text for LLM
- Add unit test to ensure LLM receives only Thought.text
- Update existing tests to expect canonical dict wire format
- Revert security analyzer changes from this PR scope

Co-authored-by: OpenHands-<MODEL_ID> openhands@all-hands.dev
2025-08-21 22:13:35 +00:00
enyst
c546547644 fix(frontend): pre-bundle i18n resources to avoid raw keys
I am OpenHands-GPT-5, an AI agent. Prime i18n resources and set backend loadPath so translations load under nested routes.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 07:43:21 +00:00
enyst
04ed9b5e3c fix(frontend): i18n backend loadPath + Reasoning label key
I am OpenHands-GPT-5, an AI agent. Fix i18n resources not loading under nested routes and replace hardcoded label with translation key.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 07:40:02 +00:00
enyst
5786595ccf feat(frontend): i18n key for Reasoning panel
I am OpenHands-GPT-5, an AI agent. Replace hardcoded label with translation key and regenerate locales.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 07:35:51 +00:00
enyst
49ee9d9d57 docs: remove PR #10432 screenshots from docs
I am OpenHands-GPT-5, an AI agent. Removing temporary Reasoning panel screenshots from the repository as requested.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 07:06:39 +00:00
enyst
6a878a8001 docs: add Reasoning panel screenshots for PR #10432
I am OpenHands-GPT-5, an AI agent. Adding collapsed and expanded screenshots of the Reasoning panel for PR review.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 06:39:26 +00:00
enyst
cf9794cd81 feat(frontend): show Reasoning panel for FinishAction when provider-native reasoning exists
I am OpenHands-GPT-5, an AI agent. Extends the structured reasoning UI to FinishAction: if event.args.thought.reasoning_content is present, render a collapsible GenericEventMessage titled 'Reasoning' above the final message.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 05:21:54 +00:00
enyst
00d08cbf9a chore: fix EOF in TASKS.md via pre-commit auto-fix
I am OpenHands-GPT-5, an AI agent.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 05:02:43 +00:00
enyst
a10d678386 Structured Thought: ensure only Thought.text reaches LLM; idempotent SecurityAnalyzer; fix config sandbox volumes handling
- ConversationMemory: coerce AgentFinishAction.thought to Thought, merge tool_call content; send only thought.text to LLM
- SecurityAnalyzer: guard against duplicate event processing; thread-safe callback bridging
- Config: ignore deprecated workspace_mount_path_in_sandbox in env to preserve '/workspace' default when SANDBOX_VOLUMES doesn't mount it

I am OpenHands-GPT-5, an AI agent. This commit addresses CI unit failures tied to Structured Thought integration and config defaults.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 01:23:16 +00:00
enyst
8d9c095d1f chore: fix end-of-file newline in TASKS.md
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 21:24:45 +00:00
enyst
a00d2a4c65 events: normalize Thought at action construction and serialization
- Add centralized Action.__post_init__ to coerce thought to Thought
- Ensure event_to_dict flattens Thought to string unless reasoning_content present
- Keep backward compatibility at (de)serialization boundaries only

Co-authored-by: OpenHands-GPT-5 <openhands@all-hands.dev>
2025-08-20 08:17:48 +00:00
OpenHands-GPT-5
196c304e2a fix(memory): support legacy string thought in conversation processing\n\n- Handle Action.thought being a string in AgentFinishAction path\n- Always emit final message using Thought.text or fallback str()\n\nCo-authored-by: OpenHands-GPT-5 <openhands@all-hands.dev> 2025-08-20 07:08:21 +00:00
OpenHands-GPT-5
d6cdfd0c04 chore(pre-commit): apply end-of-file-fixer to repo.md
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 06:31:18 +00:00
OpenHands-GPT-5
a9749d6822 docs(repo): record Thought serialization and equality rules in repo memory
- Add succinct notes on serialization (dict vs string)
- Add equality/display/prompting notes for Thought

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 06:30:52 +00:00
OpenHands-GPT-5
a0476fde32 fix(events): Thought serialization + backward compatibility
- Flatten action.thought to string when no reasoning_content; keep structured dict if present
- Add Thought.__eq__ for comparisons with str (legacy tests/uses)
- SecurityAnalyzer: safely schedule coroutine even without running loop
- Ensure OpenHands JSON encoder already handles dataclasses (no change needed)

All targeted unit tests pass locally:
- events serialization parity
- browsing agent parser
- security analyzer
- thought round-trip tests

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-20 06:30:21 +00:00
Engel Nyst
097c443c80 Fix: JSON encoder supports Thought dataclass + tests for Actions with Thought (#10513) 2025-08-20 04:28:51 +02:00
Engel Nyst
19930c4cd6 Merge branch 'main' into feature/structured-thought 2025-08-19 23:54:15 +02:00
enyst
21456a733a Fix potential hanging issues in runtime tests
- Fix combine_thought function to always return Thought objects instead of strings
- Ensure conversation memory handles empty Thought objects correctly
- These fixes address potential type inconsistencies that could cause runtime tests to hang
2025-08-19 01:37:43 +00:00
Engel Nyst
e3de03d7bc Merge branch 'main' into feature/structured-thought 2025-08-19 00:40:39 +02:00
enyst
2872d105aa style: apply pre-commit fixes
Co-authored-by: OpenHands-GPT-5 <openhands@all-hands.dev>
2025-08-18 21:48:16 +00:00
enyst
40184da146 codeact: simplify combine_thought and remove TODO NOW; tests: add action_from_dict reasoning coverage\n\n- combine_thought: remove try/except, handle Thought vs legacy string succinctly\n- add unit tests for action_from_dict covering structured and legacy cases\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-18 20:12:52 +00:00
Engel Nyst
e184140278 Update openhands/agenthub/codeact_agent/function_calling.py 2025-08-18 18:59:46 +02:00
Engel Nyst
69badd21a7 Merge branch 'main' into feature/structured-thought 2025-08-18 18:13:58 +02:00
Engel Nyst
88ce70fdc0 Merge branch 'main' into feature/structured-thought 2025-08-18 17:02:26 +02:00
Engel Nyst
0ccf802e58 Update openhands/agenthub/codeact_agent/function_calling.py 2025-08-18 05:40:50 +02:00
OpenHands Bot
381029026a 🤖 Auto-fix Python linting issues 2025-08-18 03:24:48 +00:00
Engel Nyst
cf51cee65c Merge branch 'main' into feature/structured-thought 2025-08-18 05:22:55 +02:00
enyst
5bb82c811f fix(conversation): keep getattr access and prevent reasoning_content in LLM messages; style(import): expose Thought at package level and use concise import
- Restore getattr on model_response.choices[0] for message access
- Remove provider reasoning capture when building LLM Message
- Export Thought via openhands.events.action and update readonly_agent import

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-18 02:26:21 +00:00
enyst
f4427fb623 format: apply ruff-format changes from pre-commit\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-18 02:01:50 +00:00
enyst
f1a51f723e browsing_agent: move Thought import to top-level; remove inline imports for consistency
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-18 02:01:50 +00:00
enyst
f0ab8ae7e3 controller: remove unnecessary try/except when clearing pending action thought; keep behavior for Thought vs legacy string
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-18 01:35:21 +00:00
enyst
4f58f50073 agents: move Thought import to top of function_calling.py files and remove inline imports\n\n- codeact_agent/function_calling.py: remove inline Thought import inside branch\n- readonly_agent/function_calling.py: add top-level Thought import and remove inline import\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-18 01:03:00 +00:00
Engel Nyst
cf3b9137e0 Update openhands/events/serialization/event.py 2025-08-18 03:00:18 +02:00
enyst
1d464a59f9 chore(frontend): format fixes for Thought rendering and MCP content\n\n- Prettier formatting for MCP thought block\n- Destructuring refactor in hasThoughtProperty\n- Trim stray blank lines in actions.ts\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-17 23:09:31 +00:00
enyst
511a5d396e chore(frontend): update tests and mocks for structured Thought types
- Adjust event fixtures to use { text } instead of string thought
- Fix minor syntax issues in tests

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-17 18:43:13 +00:00
enyst
0336a988e6 Merge origin/main into feature/structured-thought; keep main’s render-guard for think and our structured Thought rendering.\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-17 18:21:14 +00:00
enyst
38864093f0 FE/BE: Structured Thought end-to-end; display concatenation only; LLM input stays thought.text\n\n- Address Xingyao review 1: concat reasoning_content + text in Thought.__str__ (openhands/events/action/action.py line 42)\n- Address Xingyao review 2: make FE action args.thought a structured Thought instead of string + top-level reasoning_content (frontend/src/types/core/actions.ts line 40)\n- UI: event-message.tsx and get-action-content.ts now read thought.text and thought.reasoning_content\n- Serialization: keep nested args.thought in wire format; action_from_dict normalizes legacy string thought + top-level rc\n- ConversationMemory: ensure only thought.text is ever sent to LLM\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-17 18:02:44 +00:00
openhands
36419942a1 docs: document LiteLLM reasoning_content handling and constraints; code: simplify LiteLLM access and extraction; FE types include rc for browse_interactive
- Add .openhands/reasoning_content.md
- Prefer direct message access over getattr in conversation_memory
- Consolidate rc extraction and ensure we never send rc back to LLM
- Add reasoning_content to BrowseInteractiveAction types

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 22:37:00 +00:00
openhands
d5b26226f2 fix(frontend): avoid duplicating reasoning_content rendering; render rc+thought only once and allow messages with rc-only
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 22:21:05 +00:00
openhands
b01da8dfc0 fix(frontend): remove duplicate reasoning_content in types; compose message once to avoid duplication in EventMessage
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 22:09:06 +00:00
openhands
b037243149 feat(frontend): plumb optional reasoning_content through action types; no UI yet
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 21:52:24 +00:00
openhands
cddd282e5c feat(thought): migrate Action.thought to Thought dataclass with optional reasoning_content; wire compat maintained; capture LiteLLM reasoning; fix typing; add tests
- Introduce Thought(text, reasoning_content) with __str__/__bool__
- Switch Action subclasses thought to Thought
- event_to_dict/action_from_dict flatten/coerce Thought; support legacy
- Function-calling captures reasoning_content and uses combine_thought
- UI/memory/security updated to consume Thought; invariant trace uses string thought
- Add unit tests for serialization and legacy direct-init

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 21:39:02 +00:00
33 changed files with 565 additions and 109 deletions

13
.openhands/TASKS.md Normal file
View 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

View File

@@ -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(),

View File

@@ -58,7 +58,7 @@ describe("Messages", () => {
args: {
image_urls: [],
file_urls: [],
thought: "",
thought: { text: "" },
wait_for_response: false,
},
};

View File

@@ -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();

View File

@@ -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

View File

@@ -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",

View File

@@ -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": "ランタイムの開始を待機中...",

View File

@@ -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,

View File

@@ -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;
};
}

View File

@@ -0,0 +1,4 @@
export interface Thought {
text: string;
reasoning_content?: string | null;
}

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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)],
},

View File

@@ -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,

View File

@@ -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,

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(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}'

View File

@@ -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:

View File

@@ -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',
]

View File

@@ -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]

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.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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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

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.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

View File

@@ -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:

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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,

View File

@@ -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)}')

View File

@@ -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

View 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'

View 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