mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff1c3a2088 | |||
| b49a7164f2 | |||
| 9225a6026b | |||
| 69c900079e | |||
| f55ed151f1 | |||
| 49d37119a9 | |||
| cfd416c29f | |||
| c052dd7da5 | |||
| 3f77b8229a | |||
| 8d13c9f328 | |||
| f46b112f17 | |||
| 44dc7f9e9b | |||
| 00eaa7a6e1 | |||
| 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 |
@@ -45,6 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,12 @@
|
||||
The MIT License (MIT)
|
||||
Portions of this software are licensed as follows:
|
||||
* All content that resides under the enterprise/ directory is licensed under the license defined in "enterprise/LICENSE".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the MIT license as defined below.
|
||||
|
||||
=====================
|
||||
|
||||
Copyright © 2023
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2025
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -46,7 +46,14 @@ When running on Linux, you might run into the error `ERROR:root:<class 'httpx.Co
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Add the `--network host` to the docker run command.
|
||||
If you installed Docker from your distribution’s package repository (e.g., docker.io on Debian/Ubuntu), be aware that
|
||||
these packages can sometimes be outdated or include changes that cause compatibility issues. try reinstalling Docker
|
||||
[using the official instructions](https://docs.docker.com/engine/install/) to ensure you are running a compatible version.
|
||||
|
||||
If that does not solve the issue, try incrementally adding the following parameters to the docker run command:
|
||||
* `--network host`
|
||||
* `-e SANDBOX_USE_HOST_NETWORK=true`
|
||||
* `-e DOCKER_HOST_ADDR=127.0.0.1`
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software
|
||||
to do everything you might do with the software that would
|
||||
otherwise infringe the licensor's copyright in it for any
|
||||
permitted purpose. However, you may only make changes or
|
||||
new works based on the software according to [Changes and New
|
||||
Works License](#changes-and-new-works-license), and you may
|
||||
not distribute copies of the software.
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to
|
||||
make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## Free Trial
|
||||
|
||||
Use of the software for more than 30 days per calendar year is not allowed without a commercial license.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
If you violate any of these terms, or do anything with the
|
||||
software not covered by your licenses, all your licenses
|
||||
end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control of,
|
||||
or are under common control with that organization. **Control**
|
||||
means ownership of substantially all the assets of an entity,
|
||||
or the power to direct its management and policies by vote,
|
||||
contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
-58
@@ -2545,64 +2545,6 @@ describe("MicroagentManagement", () => {
|
||||
screen.queryByTestId("learn-this-repo-trigger"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle API call for branches when learn this repo modal opens", async () => {
|
||||
// Mock branch API
|
||||
const branchesSpy = vi
|
||||
.spyOn(OpenHands, "getRepositoryBranches")
|
||||
.mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "abc123", protected: false },
|
||||
{ name: "develop", commit_sha: "def456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
|
||||
// Mock other APIs
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
const searchConversationsSpy = vi.spyOn(OpenHands, "searchConversations");
|
||||
getRepositoryMicroagentsSpy.mockResolvedValue([]);
|
||||
searchConversationsSpy.mockResolvedValue([]);
|
||||
|
||||
// Test with direct Redux state that has modal visible
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: null,
|
||||
addMicroagentModalVisible: false,
|
||||
updateMicroagentModalVisible: false,
|
||||
learnThisRepoModalVisible: true, // Modal should be visible
|
||||
selectedRepository: {
|
||||
id: "1",
|
||||
full_name: "test-org/test-repo",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The branches API should be called when the modal is visible
|
||||
await waitFor(() => {
|
||||
expect(branchesSpy).toHaveBeenCalledWith("test-org/test-repo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Learn something new button functionality tests
|
||||
|
||||
@@ -31,6 +31,7 @@ import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
import { SubscriptionAccess } from "#/types/billing";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -433,6 +434,13 @@ class OpenHands {
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
|
||||
@@ -49,13 +49,11 @@ export interface GetConfigResponse {
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
|
||||
@@ -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
|
||||
|
||||
-1
@@ -288,7 +288,6 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions: formData.query,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
|
||||
+3
-114
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -10,13 +10,6 @@ import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
|
||||
import { LearnThisRepoFormData } from "#/types/microagent-management";
|
||||
import { Branch } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementLearnThisRepoModalProps {
|
||||
onConfirm: (formData: LearnThisRepoFormData) => void;
|
||||
@@ -32,47 +25,11 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = useRef<boolean>(false);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
// Auto-select main or master branch if it exists.
|
||||
useEffect(() => {
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
}
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -83,7 +40,6 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,66 +51,9 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
// Reset the manually cleared flag when a branch is explicitly selected
|
||||
branchManuallyClearedRef.current = false;
|
||||
};
|
||||
|
||||
const handleBranchInputChange = (value: string) => {
|
||||
// Clear the selected branch if the input is empty or contains only whitespace
|
||||
// This fixes the issue where users can't delete the entire default branch name
|
||||
if (value === "" || value.trim() === "") {
|
||||
setSelectedBranch(null);
|
||||
// Set the flag to indicate that the branch was manually cleared
|
||||
branchManuallyClearedRef.current = true;
|
||||
} else {
|
||||
// Reset the flag when the user starts typing again
|
||||
branchManuallyClearedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Render the appropriate UI for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<ModalBody
|
||||
@@ -200,9 +99,6 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<div data-testid="branch-selector-container">
|
||||
{renderBranchSelector()}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
@@ -245,16 +141,9 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError
|
||||
}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{isLoading || isLoadingBranches
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
{isLoading ? t(I18nKey.HOME$LOADING) : t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -37,9 +37,6 @@ export function Sidebar() {
|
||||
const shouldHideLlmSettings =
|
||||
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
|
||||
|
||||
const shouldHideMicroagentManagement =
|
||||
config?.FEATURE_FLAGS.HIDE_MICROAGENT_MANAGEMENT;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldHideLlmSettings) return;
|
||||
|
||||
@@ -83,11 +80,9 @@ export function Sidebar() {
|
||||
}
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
{!shouldHideMicroagentManagement && (
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
)}
|
||||
<MicroagentManagementButton
|
||||
disabled={settings?.EMAIL_VERIFIED === false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useSubscriptionAccess = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "subscription_access"],
|
||||
queryFn: OpenHands.getSubscriptionAccess,
|
||||
enabled:
|
||||
!isOnTosPage &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
config?.FEATURE_FLAGS?.ENABLE_BILLING,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -11984,20 +11984,20 @@
|
||||
"uk": "Бажаєте, щоб OpenHands розпочав нову розмову, щоб допомогти вам зрозуміти цей репозиторій?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "What would you like to know about this repository?",
|
||||
"ja": "このリポジトリについて何を知りたいですか?",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요?",
|
||||
"no": "Hva vil du vite om dette depotet?",
|
||||
"it": "Cosa vorresti sapere su questo repository?",
|
||||
"pt": "O que você gostaria de saber sobre este repositório?",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio?",
|
||||
"ar": "ماذا تريد أن تعرف عن هذا المستودع؟",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ?",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz?",
|
||||
"de": "Was möchten Sie über dieses Repository wissen?",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій?"
|
||||
"en": "What would you like to know about this repository? (optional)",
|
||||
"ja": "このリポジトリについて知りたいことは何ですか?(任意)",
|
||||
"zh-CN": "您想了解此存储库的哪些内容?(可选)",
|
||||
"zh-TW": "您想了解此存儲庫的哪些內容?(選填)",
|
||||
"ko-KR": "이 저장소에 대해 무엇을 알고 싶으신가요? (선택 사항)",
|
||||
"no": "Hva vil du vite om dette depotet? (valgfritt)",
|
||||
"it": "Cosa vorresti sapere su questo repository? (opzionale)",
|
||||
"pt": "O que você gostaria de saber sobre este repositório? (opcional)",
|
||||
"es": "¿Qué te gustaría saber sobre este repositorio? (opcional)",
|
||||
"ar": "ماذا ترغب في معرفته عن هذا المستودع؟ (اختياري)",
|
||||
"fr": "Que souhaitez-vous savoir sur ce dépôt ? (facultatif)",
|
||||
"tr": "Bu depo hakkında ne bilmek istersiniz? (isteğe bağlı)",
|
||||
"de": "Was möchten Sie über dieses Repository wissen? (optional)",
|
||||
"uk": "Що ви хотіли б дізнатися про цей репозиторій? (необов'язково)"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO": {
|
||||
"en": "Describe what you would like to know about this repository.",
|
||||
@@ -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": "ランタイムの開始を待機中...",
|
||||
|
||||
@@ -169,7 +169,6 @@ export const handlers = [
|
||||
APP_MODE: mockSaas ? "saas" : "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
STRIPE_PUBLISHABLE_KEY: "",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: mockSaas,
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
@@ -8,6 +9,7 @@ import { Route } from "./+types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
|
||||
const SAAS_ONLY_PATHS = [
|
||||
"/settings/user",
|
||||
@@ -62,10 +64,22 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
const navItems = useMemo(() => {
|
||||
const items = [];
|
||||
if (isSaas) {
|
||||
if (subscriptionAccess) {
|
||||
items.push({ to: "/settings", text: "SETTINGS$NAV_LLM" });
|
||||
}
|
||||
items.push(...SAAS_NAV_ITEMS);
|
||||
} else {
|
||||
items.push(...OSS_NAV_ITEMS);
|
||||
}
|
||||
return items;
|
||||
}, [isSaas, !!subscriptionAccess]);
|
||||
|
||||
return (
|
||||
<main
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SubscriptionAccess = {
|
||||
status: "ACTIVE" | "DISABLED";
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface Thought {
|
||||
text: string;
|
||||
reasoning_content?: string | null;
|
||||
}
|
||||
@@ -22,5 +22,4 @@ export interface MicroagentFormData {
|
||||
|
||||
export interface LearnThisRepoFormData {
|
||||
query: string;
|
||||
selectedBranch: string;
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,7 +2,7 @@ import io
|
||||
import re
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import ClassVar, Union
|
||||
|
||||
import frontmatter
|
||||
from pydantic import BaseModel
|
||||
@@ -23,6 +23,31 @@ class BaseMicroagent(BaseModel):
|
||||
source: str # path to the file
|
||||
type: MicroagentType
|
||||
|
||||
PATH_TO_THIRD_PARTY_MICROAGENT_NAME: ClassVar[dict[str, str]] = {
|
||||
'.cursorrules': 'cursorrules',
|
||||
'agents.md': 'agents',
|
||||
'agent.md': 'agents',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _handle_third_party(
|
||||
cls, path: Path, file_content: str
|
||||
) -> Union['RepoMicroagent', None]:
|
||||
# Determine the agent name based on file type
|
||||
microagent_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(path.name.lower())
|
||||
|
||||
# Create RepoMicroagent if we recognized the file type
|
||||
if microagent_name is not None:
|
||||
return RepoMicroagent(
|
||||
name=microagent_name,
|
||||
content=file_content,
|
||||
metadata=MicroagentMetadata(name=microagent_name),
|
||||
source=str(path),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
@@ -40,11 +65,10 @@ class BaseMicroagent(BaseModel):
|
||||
# Otherwise, we will rely on the name from metadata later
|
||||
derived_name = None
|
||||
if microagent_dir is not None:
|
||||
# Special handling for .cursorrules files which are not in microagent_dir
|
||||
if path.name == '.cursorrules':
|
||||
derived_name = 'cursorrules'
|
||||
else:
|
||||
derived_name = str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
# Special handling for files which are not in microagent_dir
|
||||
derived_name = cls.PATH_TO_THIRD_PARTY_MICROAGENT_NAME.get(
|
||||
path.name.lower()
|
||||
) or str(path.relative_to(microagent_dir).with_suffix(''))
|
||||
|
||||
# Only load directly from path if file_content is not provided
|
||||
if file_content is None:
|
||||
@@ -61,15 +85,10 @@ class BaseMicroagent(BaseModel):
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
|
||||
# Handle .cursorrules files
|
||||
if path.name == '.cursorrules':
|
||||
return RepoMicroagent(
|
||||
name='cursorrules',
|
||||
content=file_content,
|
||||
metadata=MicroagentMetadata(name='cursorrules'),
|
||||
source=str(path),
|
||||
type=MicroagentType.REPO_KNOWLEDGE,
|
||||
)
|
||||
# Handle third-party agent instruction files
|
||||
third_party_agent = cls._handle_third_party(path, file_content)
|
||||
if third_party_agent is not None:
|
||||
return third_party_agent
|
||||
|
||||
file_io = io.StringIO(file_content)
|
||||
loaded = frontmatter.load(file_io)
|
||||
@@ -276,31 +295,44 @@ def load_microagents_from_dir(
|
||||
|
||||
# Load all agents from microagents directory
|
||||
logger.debug(f'Loading agents from {microagent_dir}')
|
||||
if microagent_dir.exists():
|
||||
# Collect .cursorrules file from repo root and .md files from microagents dir
|
||||
cursorrules_files = []
|
||||
if (microagent_dir.parent.parent / '.cursorrules').exists():
|
||||
cursorrules_files = [microagent_dir.parent.parent / '.cursorrules']
|
||||
|
||||
# Always check for .cursorrules and AGENTS.md files in repo root, regardless of whether microagents_dir exists
|
||||
special_files = []
|
||||
repo_root = microagent_dir.parent.parent
|
||||
|
||||
# Check for .cursorrules
|
||||
if (repo_root / '.cursorrules').exists():
|
||||
special_files.append(repo_root / '.cursorrules')
|
||||
|
||||
# Check for AGENTS.md (case-insensitive)
|
||||
for agents_filename in ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']:
|
||||
agents_path = repo_root / agents_filename
|
||||
if agents_path.exists():
|
||||
special_files.append(agents_path)
|
||||
break # Only add the first one found to avoid duplicates
|
||||
|
||||
# Collect .md files from microagents directory if it exists
|
||||
md_files = []
|
||||
if microagent_dir.exists():
|
||||
md_files = [f for f in microagent_dir.rglob('*.md') if f.name != 'README.md']
|
||||
|
||||
# Process all files in one loop
|
||||
for file in chain(cursorrules_files, md_files):
|
||||
try:
|
||||
agent = BaseMicroagent.load(file, microagent_dir)
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise MicroagentValidationError(error_msg) from e
|
||||
except Exception as e:
|
||||
# For other errors, wrap in a ValueError with detailed message
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise ValueError(error_msg) from e
|
||||
# Process all files in one loop
|
||||
for file in chain(special_files, md_files):
|
||||
try:
|
||||
agent = BaseMicroagent.load(file, microagent_dir)
|
||||
if isinstance(agent, RepoMicroagent):
|
||||
repo_agents[agent.name] = agent
|
||||
elif isinstance(agent, KnowledgeMicroagent):
|
||||
# Both KnowledgeMicroagent and TaskMicroagent go into knowledge_agents
|
||||
knowledge_agents[agent.name] = agent
|
||||
except MicroagentValidationError as e:
|
||||
# For validation errors, include the original exception
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise MicroagentValidationError(error_msg) from e
|
||||
except Exception as e:
|
||||
# For other errors, wrap in a ValueError with detailed message
|
||||
error_msg = f'Error loading microagent from {file}: {str(e)}'
|
||||
raise ValueError(error_msg) from e
|
||||
|
||||
logger.debug(
|
||||
f'Loaded {len(repo_agents) + len(knowledge_agents)} microagents: '
|
||||
|
||||
@@ -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)}')
|
||||
|
||||
@@ -13,7 +13,6 @@ class ServerConfig(ServerConfigInterface):
|
||||
enable_billing = os.environ.get('ENABLE_BILLING', 'false') == 'true'
|
||||
hide_llm_settings = os.environ.get('HIDE_LLM_SETTINGS', 'false') == 'true'
|
||||
# This config is used to hide the microagent management page from the users for now. We will remove this once we release the new microagent management page.
|
||||
hide_microagent_management = True
|
||||
settings_store_class: str = (
|
||||
'openhands.storage.settings.file_settings_store.FileSettingsStore'
|
||||
)
|
||||
@@ -44,7 +43,6 @@ class ServerConfig(ServerConfigInterface):
|
||||
'FEATURE_FLAGS': {
|
||||
'ENABLE_BILLING': self.enable_billing,
|
||||
'HIDE_LLM_SETTINGS': self.hide_llm_settings,
|
||||
'HIDE_MICROAGENT_MANAGEMENT': self.hide_microagent_management,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -364,3 +364,184 @@ def test_load_microagents_with_cursorrules(temp_microagents_dir_with_cursorrules
|
||||
assert cursorrules_agent.name == 'cursorrules'
|
||||
assert 'Always use TypeScript for new files' in cursorrules_agent.content
|
||||
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_cursorrules_only():
|
||||
"""Create a temporary directory with only .cursorrules file (no .openhands/microagents directory)."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create .cursorrules file in repository root
|
||||
cursorrules_content = """Always use Python for new files.
|
||||
Follow PEP 8 style guidelines."""
|
||||
(root / '.cursorrules').write_text(cursorrules_content)
|
||||
|
||||
# Note: We intentionally do NOT create .openhands/microagents directory
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_cursorrules_without_microagents_dir(temp_dir_with_cursorrules_only):
|
||||
"""Test loading .cursorrules file when .openhands/microagents directory doesn't exist.
|
||||
|
||||
This test reproduces the bug where .cursorrules is only loaded when
|
||||
.openhands/microagents directory exists.
|
||||
"""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = temp_dir_with_cursorrules_only / '.openhands' / 'microagents'
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find the .cursorrules file even though microagents_dir doesn't exist
|
||||
assert len(repo_agents) == 1 # Only .cursorrules
|
||||
assert 'cursorrules' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check .cursorrules agent
|
||||
cursorrules_agent = repo_agents['cursorrules']
|
||||
assert isinstance(cursorrules_agent, RepoMicroagent)
|
||||
assert cursorrules_agent.name == 'cursorrules'
|
||||
assert 'Always use Python for new files' in cursorrules_agent.content
|
||||
assert cursorrules_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
def test_agents_md_file_load():
|
||||
"""Test loading AGENTS.md file as a RepoMicroagent."""
|
||||
agents_content = """# Project Setup
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `npm install`
|
||||
- Start dev server: `npm run dev`
|
||||
- Run tests: `npm test`
|
||||
|
||||
## Code style
|
||||
|
||||
- TypeScript strict mode
|
||||
- Single quotes, no semicolons
|
||||
- Use functional patterns where possible"""
|
||||
|
||||
agents_path = Path('AGENTS.md')
|
||||
|
||||
# Test loading AGENTS.md file directly
|
||||
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
|
||||
|
||||
# Verify it's loaded as a RepoMicroagent
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'agents'
|
||||
assert agent.content == agents_content
|
||||
assert agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
assert agent.metadata.name == 'agents'
|
||||
assert agent.source == str(agents_path)
|
||||
|
||||
|
||||
def test_agents_md_case_insensitive():
|
||||
"""Test that AGENTS.md loading is case-insensitive."""
|
||||
agents_content = """# Development Guide
|
||||
|
||||
Use TypeScript for all new files."""
|
||||
|
||||
test_cases = ['AGENTS.md', 'agents.md', 'AGENT.md', 'agent.md']
|
||||
|
||||
for filename in test_cases:
|
||||
agents_path = Path(filename)
|
||||
agent = BaseMicroagent.load(agents_path, file_content=agents_content)
|
||||
|
||||
assert isinstance(agent, RepoMicroagent)
|
||||
assert agent.name == 'agents'
|
||||
assert agent.content == agents_content
|
||||
assert agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_agents_md_only():
|
||||
"""Create a temporary directory with only AGENTS.md file (no .openhands/microagents directory)."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create AGENTS.md file in repository root
|
||||
agents_content = """# Development Guide
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `poetry install`
|
||||
- Start dev server: `poetry run python app.py`
|
||||
- Run tests: `poetry run pytest`
|
||||
|
||||
## Code style
|
||||
|
||||
- Python 3.12+
|
||||
- Follow PEP 8 guidelines
|
||||
- Use type hints everywhere"""
|
||||
(root / 'AGENTS.md').write_text(agents_content)
|
||||
|
||||
# Note: We intentionally do NOT create .openhands/microagents directory
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_agents_md_without_microagents_dir(temp_dir_with_agents_md_only):
|
||||
"""Test loading AGENTS.md file when .openhands/microagents directory doesn't exist."""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = temp_dir_with_agents_md_only / '.openhands' / 'microagents'
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find the AGENTS.md file even though microagents_dir doesn't exist
|
||||
assert len(repo_agents) == 1 # Only AGENTS.md
|
||||
assert 'agents' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check AGENTS.md agent
|
||||
agents_agent = repo_agents['agents']
|
||||
assert isinstance(agents_agent, RepoMicroagent)
|
||||
assert agents_agent.name == 'agents'
|
||||
assert 'Install deps: `poetry install`' in agents_agent.content
|
||||
assert agents_agent.type == MicroagentType.REPO_KNOWLEDGE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_with_both_cursorrules_and_agents():
|
||||
"""Create a temporary directory with both .cursorrules and AGENTS.md files."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
# Create .cursorrules file
|
||||
cursorrules_content = """Always use Python for new files.
|
||||
Follow PEP 8 style guidelines."""
|
||||
(root / '.cursorrules').write_text(cursorrules_content)
|
||||
|
||||
# Create AGENTS.md file
|
||||
agents_content = """# Development Guide
|
||||
|
||||
## Setup commands
|
||||
|
||||
- Install deps: `poetry install`
|
||||
- Run tests: `poetry run pytest`"""
|
||||
(root / 'AGENTS.md').write_text(agents_content)
|
||||
|
||||
yield root
|
||||
|
||||
|
||||
def test_load_both_cursorrules_and_agents_md(temp_dir_with_both_cursorrules_and_agents):
|
||||
"""Test loading both .cursorrules and AGENTS.md files when .openhands/microagents doesn't exist."""
|
||||
# Try to load from non-existent microagents directory
|
||||
microagents_dir = (
|
||||
temp_dir_with_both_cursorrules_and_agents / '.openhands' / 'microagents'
|
||||
)
|
||||
|
||||
repo_agents, knowledge_agents = load_microagents_from_dir(microagents_dir)
|
||||
|
||||
# This should find both files
|
||||
assert len(repo_agents) == 2 # .cursorrules + AGENTS.md
|
||||
assert 'cursorrules' in repo_agents
|
||||
assert 'agents' in repo_agents
|
||||
assert len(knowledge_agents) == 0
|
||||
|
||||
# Check both agents
|
||||
cursorrules_agent = repo_agents['cursorrules']
|
||||
assert isinstance(cursorrules_agent, RepoMicroagent)
|
||||
assert 'Always use Python for new files' in cursorrules_agent.content
|
||||
|
||||
agents_agent = repo_agents['agents']
|
||||
assert isinstance(agents_agent, RepoMicroagent)
|
||||
assert 'Install deps: `poetry install`' in agents_agent.content
|
||||
|
||||
Reference in New Issue
Block a user