Compare commits

..

24 Commits

Author SHA1 Message Date
openhands
7a2e7d6270 Revert changes to pyproject.toml 2025-03-20 17:01:50 +00:00
openhands
f5c58adaf7 Refactor: Clean up WebSocket client code and fix logging 2025-03-20 16:49:38 +00:00
Xingyao Wang
c6cb025afe Merge branch 'main' into allow-message-during-client-loading 2025-03-20 12:41:16 -04:00
Rohit Malhotra
b0030d3a2b [Bug]: Use json dumps instead of str repr to prevent escape character mismatches (#7369) 2025-03-20 10:33:15 -04:00
sp.wack
d76477099c chore(frontend): Hardcode feature flag values (#7360)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-03-20 13:36:49 +00:00
tawago
3e3b2aaa5c Rename --repo argument to --selected-repo to avoid confusion in the resolver workflow (#7287)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-03-20 05:01:00 +00:00
Robert Brennan
1f8aa93843 revert runtime for resolver (#7365) 2025-03-20 04:52:43 +00:00
Engel Nyst
34920ea04e Save agent state (#7372) 2025-03-20 05:16:49 +01:00
Graham Neubig
f5aeb47a72 Fix homepage internationalization (Issue #7355) (#7359)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-19 17:48:30 -04:00
Engel Nyst
c830177207 Move security.md to the global microagents (#7361) 2025-03-20 05:40:05 +08:00
openhands
cb8214676e Simplify frontend logic by removing unnecessary backend readiness handling 2025-03-19 15:21:48 +00:00
openhands
fdfb7308b8 Move message queueing from frontend to backend 2025-03-19 15:06:16 +00:00
Xingyao Wang
4785de91b0 Merge branch 'main' into allow-message-during-client-loading 2025-03-19 10:59:12 -04:00
Xingyao Wang
effd2b7d06 Merge branch 'main' into allow-message-during-client-loading 2025-03-05 13:41:43 -05:00
openhands
608dd8f2c2 Fix linting issues 2025-03-04 01:59:59 +00:00
openhands
6d0c03509e Simplify backend ready detection and message sending 2025-03-03 22:39:10 +00:00
openhands
3e1070bbe9 Add enhanced logging to debug WebSocket connection and message queuing 2025-03-03 22:36:29 +00:00
openhands
2045350720 Fix message queuing by waiting for backend ready signal 2025-03-03 22:27:54 +00:00
openhands
5f83d4cf9a Add info method to EventLogger 2025-03-03 22:25:43 +00:00
openhands
d5a996a9e1 Update i18n declaration file 2025-02-28 02:27:47 +00:00
openhands
b0d38bbeb8 Merge main into allow-message-during-client-loading and resolve conflicts 2025-02-28 02:26:10 +00:00
openhands
ed50b3ee8f Fix agent response by sending agent state change event 2025-02-27 17:31:17 +00:00
openhands
4e5ed36213 Fix linting issue with queueMessage function order 2025-02-27 16:36:05 +00:00
openhands
9060452af6 Allow sending messages while client is connecting 2025-02-27 16:21:30 +00:00
22 changed files with 316 additions and 433 deletions

View File

@@ -74,13 +74,13 @@ jobs:
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -106,7 +106,7 @@ jobs:
contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: useblacksmith/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}

View File

@@ -402,5 +402,26 @@
"theme.unlistedContent.message": {
"message": "Cette page n'est pas répertoriée. Les moteurs de recherche ne l'indexeront pas, et seuls les utilisateurs ayant un lien direct peuvent y accéder.",
"description": "The unlisted content banner message"
},
"Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.": {
"message": "Utilisez l'IA pour gérer les tâches répétitives de votre backlog. Nos agents disposent des mêmes outils qu'un développeur humain : ils peuvent modifier du code, exécuter des commandes, naviguer sur le web, appeler des API et même copier des extraits de code depuis StackOverflow."
},
"Get started with OpenHands.": {
"message": "Commencer avec OpenHands"
},
"Most Popular Links": {
"message": "Liens Populaires"
},
"Customizing OpenHands to a repository": {
"message": "Personnaliser OpenHands pour un dépôt"
},
"Integrating OpenHands with Github": {
"message": "Intégrer OpenHands avec Github"
},
"Recommended models to use": {
"message": "Modèles recommandés"
},
"Connecting OpenHands to your filesystem": {
"message": "Connecter OpenHands à votre système de fichiers"
}
}

View File

@@ -402,5 +402,26 @@
"theme.unlistedContent.message": {
"message": "此页面未列出。搜索引擎不会对其索引,只有拥有直接链接的用户才能访问。",
"description": "The unlisted content banner message"
},
"Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.": {
"message": "使用AI处理您积压的工作。我们的代理拥有与人类开发者相同的工具它们可以修改代码、运行命令、浏览网页、调用API甚至从StackOverflow复制代码片段。"
},
"Get started with OpenHands.": {
"message": "开始使用OpenHands"
},
"Most Popular Links": {
"message": "热门链接"
},
"Customizing OpenHands to a repository": {
"message": "为仓库定制OpenHands"
},
"Integrating OpenHands with Github": {
"message": "将OpenHands与Github集成"
},
"Recommended models to use": {
"message": "推荐使用的模型"
},
"Connecting OpenHands to your filesystem": {
"message": "将OpenHands连接到您的文件系统"
}
}

View File

@@ -25,17 +25,19 @@ export function HomepageHeader() {
padding: '0rem 0rem 1rem'
}}>
<p style={{ margin: '0' }}>
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web,
call APIs, and yes-even copy code snippets from StackOverflow.
<Translate>
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web,
call APIs, and yes-even copy code snippets from StackOverflow.
</Translate>
<br/>
<Link to="https://docs.all-hands.dev/modules/usage/installation"
<Link to="/modules/usage/installation"
style={{
textDecoration: 'underline',
display: 'inline-block',
marginTop: '0.5rem'
}}
>
Get started with OpenHands.
<Translate>Get started with OpenHands.</Translate>
</Link>
</p>
</div>

View File

@@ -2,6 +2,8 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import { HomepageHeader } from '../components/HomepageHeader/HomepageHeader';
import { translate } from '@docusaurus/Translate';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import { Demo } from "../components/Demo/Demo";
export default function Home(): JSX.Element {
@@ -21,12 +23,28 @@ export default function Home(): JSX.Element {
</div>
<div style={{ textAlign: 'center', padding: '0.5rem 2rem 1.5rem' }}>
<h2>Most Popular Links</h2>
<h2><Translate>Most Popular Links</Translate></h2>
<ul style={{ listStyleType: 'none'}}>
<li><a href="/modules/usage/prompting/microagents-repo">Customizing OpenHands to a repository</a></li>
<li><a href="/modules/usage/how-to/github-action">Integrating OpenHands with Github</a></li>
<li><a href="/modules/usage/llms#model-recommendations">Recommended models to use</a></li>
<li><a href="/modules/usage/runtimes#connecting-to-your-filesystem">Connecting OpenHands to your filesystem</a></li>
<li>
<Link to="/modules/usage/prompting/microagents-repo">
<Translate>Customizing OpenHands to a repository</Translate>
</Link>
</li>
<li>
<Link to="/modules/usage/how-to/github-action">
<Translate>Integrating OpenHands with Github</Translate>
</Link>
</li>
<li>
<Link to="/modules/usage/llms#model-recommendations">
<Translate>Recommended models to use</Translate>
</Link>
</li>
<li>
<Link to="/modules/usage/runtimes#connecting-to-your-filesystem">
<Translate>Connecting OpenHands to your filesystem</Translate>
</Link>
</li>
</ul>
</div>
</Layout>

View File

@@ -13,7 +13,10 @@ import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ActionSuggestions } from "./action-suggestions";
@@ -34,7 +37,7 @@ function getEntryPoint(
}
export function ChatInterface() {
const { send, isLoadingMessages } = useWsClient();
const { send, isLoadingMessages, status, pendingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
@@ -54,6 +57,9 @@ export function ChatInterface() {
const params = useParams();
const { mutate: getTrajectory } = useGetTrajectory();
const isClientDisconnected = status === WsClientProviderStatus.DISCONNECTED;
const hasPendingMessages = pendingMessages.length > 0;
const handleSendMessage = async (content: string, files: File[]) => {
if (messages.length === 0) {
posthog.capture("initial_query_submitted", {
@@ -76,7 +82,15 @@ export function ChatInterface() {
const timestamp = new Date().toISOString();
const pending = true;
dispatch(addUserMessage({ content, imageUrls, timestamp, pending }));
send(createChatMessage(content, imageUrls, timestamp));
// Create and send the chat message
const chatMessage = createChatMessage(content, imageUrls, timestamp);
send(chatMessage);
// Send the agent state change event immediately
// The backend will handle the ordering and queueing
send(generateAgentStateChangeEvent(AgentState.RUNNING));
setMessageToSend(null);
};
@@ -131,8 +145,20 @@ export function ChatInterface() {
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && (
<div className="flex justify-center">
<div className="flex flex-col items-center gap-2">
<LoadingSpinner size="small" />
{isClientDisconnected && (
<div className="text-sm text-neutral-400">
Waiting for client to become ready...
{hasPendingMessages && (
<div className="text-xs text-neutral-500 mt-1">
{pendingMessages.length} message
{pendingMessages.length !== 1 ? "s" : ""} will be sent when
connected
</div>
)}
</div>
)}
</div>
)}
@@ -179,7 +205,7 @@ export function ChatInterface() {
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
// Allow input even when loading, but not during confirmation
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}

View File

@@ -22,10 +22,11 @@ export function AgentStatusBar() {
const { t, i18n } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const { status } = useWsClient();
const { status, pendingMessages } = useWsClient();
const { notify } = useNotification();
const [statusMessage, setStatusMessage] = React.useState<string>("");
const hasPendingMessages = pendingMessages.length > 0;
const updateStatusMessage = () => {
let message = curStatusMessage.message || "";
@@ -71,7 +72,13 @@ export function AgentStatusBar() {
React.useEffect(() => {
if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage("Connecting...");
if (hasPendingMessages) {
setStatusMessage(
`Connecting... (${pendingMessages.length} pending message${pendingMessages.length !== 1 ? "s" : ""})`,
);
} else {
setStatusMessage("Connecting...");
}
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
if (notificationStates.includes(curAgentState)) {
@@ -87,7 +94,7 @@ export function AgentStatusBar() {
}
}
}
}, [curAgentState, notify, t]);
}, [curAgentState, status, pendingMessages.length, notify, t]);
return (
<div className="flex flex-col items-center">

View File

@@ -49,12 +49,14 @@ interface UseWsClient {
isLoadingMessages: boolean;
events: Record<string, unknown>[];
send: (event: Record<string, unknown>) => void;
pendingMessages: Record<string, unknown>[];
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.DISCONNECTED,
isLoadingMessages: true,
events: [],
pendingMessages: [],
send: () => {
throw new Error("not connected");
},
@@ -109,26 +111,43 @@ export function WsClientProvider({
WsClientProviderStatus.DISCONNECTED,
);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const [pendingMessages, setPendingMessages] = React.useState<
Record<string, unknown>[]
>([]);
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const messageRateHandler = useRate({ threshold: 250 });
// Private function to queue messages for later sending
const queueMessage = (event: Record<string, unknown>) => {
EventLogger.info(`Queueing message: ${JSON.stringify(event)}`);
setPendingMessages((prev) => [...prev, event]);
};
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
EventLogger.error("WebSocket is not connected.");
EventLogger.info("WebSocket is not connected, queueing message");
queueMessage(event);
return;
}
// Send the message to the backend
EventLogger.info(`Sending message: ${JSON.stringify(event)}`);
sioRef.current.emit("oh_action", event);
}
function handleConnect() {
setStatus(WsClientProviderStatus.CONNECTED);
EventLogger.info(
`WebSocket connected. Pending messages: ${pendingMessages.length}`,
);
}
function handleMessage(event: Record<string, unknown>) {
if (isOpenHandsEvent(event) && isMessageAction(event)) {
messageRateHandler.record(new Date().getTime());
}
setEvents((prevEvents) => [...prevEvents, event]);
if (!Number.isNaN(parseInt(event.id as string, 10))) {
lastEventRef.current = event;
@@ -145,14 +164,39 @@ export function WsClientProvider({
}
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
EventLogger.info(
`WebSocket disconnected. Latest event ID: ${lastEventRef.current?.id}`,
);
updateStatusWhenErrorMessagePresent(data);
}
function handleError(data: unknown) {
setStatus(WsClientProviderStatus.DISCONNECTED);
EventLogger.error(`WebSocket connection error: ${JSON.stringify(data)}`);
updateStatusWhenErrorMessagePresent(data);
}
// Process any pending messages when the WebSocket connects
React.useEffect(() => {
if (
status === WsClientProviderStatus.CONNECTED &&
pendingMessages.length > 0 &&
sioRef.current
) {
// We're connected and have pending messages
EventLogger.info(
`Connected! Sending ${pendingMessages.length} queued messages`,
);
pendingMessages.forEach((event) => {
sioRef.current?.emit("oh_action", event);
});
setPendingMessages([]);
EventLogger.info("All queued messages sent, queue cleared");
}
}, [status, pendingMessages.length]);
React.useEffect(() => {
lastEventRef.current = null;
}, [conversationId]);
@@ -210,9 +254,10 @@ export function WsClientProvider({
status,
isLoadingMessages: messageRateHandler.isUnderThreshold,
events,
pendingMessages,
send,
}),
[status, messageRateHandler.isUnderThreshold, events],
[status, messageRateHandler.isUnderThreshold, events, pendingMessages],
);
return <WsClientContext value={value}>{children}</WsClientContext>;

View File

@@ -1,18 +1,28 @@
/* eslint-disable no-console */
/**
* A utility class for logging events. This class will only log events in development mode.
* A utility class for logging events. This class will log events in development mode
* and can be forced to log in any environment by setting FORCE_LOGGING to true.
*/
class EventLogger {
static isDevMode = process.env.NODE_ENV === "development";
static FORCE_LOGGING = false; // Set to false for production, true only for debugging
static shouldLog() {
return this.isDevMode || this.FORCE_LOGGING;
}
/**
* Format and log a message event
* @param event The raw event object
*/
static message(event: MessageEvent) {
if (this.isDevMode) {
console.warn(JSON.stringify(JSON.parse(event.data.toString()), null, 2));
if (this.shouldLog()) {
console.warn(
"[OpenHands]",
JSON.stringify(JSON.parse(event.data.toString()), null, 2),
);
}
}
@@ -22,8 +32,8 @@ class EventLogger {
* @param name The name of the event
*/
static event(event: Event, name?: string) {
if (this.isDevMode) {
console.warn(name || "EVENT", event);
if (this.shouldLog()) {
console.warn("[OpenHands]", name || "EVENT", event);
}
}
@@ -32,8 +42,18 @@ class EventLogger {
* @param warning The warning message
*/
static warning(warning: string) {
if (this.isDevMode) {
console.warn(warning);
if (this.shouldLog()) {
console.warn("[OpenHands]", warning);
}
}
/**
* Log an info message
* @param info The info message
*/
static info(info: string) {
if (this.shouldLog()) {
console.info("[OpenHands]", info);
}
}
@@ -42,8 +62,8 @@ class EventLogger {
* @param error The error message
*/
static error(error: string) {
if (this.isDevMode) {
console.error(error);
if (this.shouldLog()) {
console.error("[OpenHands]", error);
}
}
}

View File

@@ -12,5 +12,7 @@ function loadFeatureFlag(
}
}
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const BILLING_SETTINGS = () =>
true || loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () =>
true || loadFeatureFlag("HIDE_LLM_SETTINGS");

View File

@@ -20,7 +20,6 @@ from openhands.agenthub.codeact_agent.tools import (
create_cmd_run_tool,
create_str_replace_editor_tool,
)
from openhands.agenthub.codeact_agent.tools.delegate import DelegateTool
from openhands.core.exceptions import (
FunctionCallNotExistsError,
FunctionCallValidationError,
@@ -100,18 +99,10 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
f'Missing required argument "code" in tool call {tool_call.function.name}'
)
action = IPythonRunCellAction(code=arguments['code'])
elif tool_call.function.name == DelegateTool['function']['name']:
if 'agent' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "agent" in tool call {tool_call.function.name}'
)
if 'inputs' not in arguments:
raise FunctionCallValidationError(
f'Missing required argument "inputs" in tool call {tool_call.function.name}'
)
elif tool_call.function.name == 'delegate_to_browsing_agent':
action = AgentDelegateAction(
agent=arguments['agent'],
inputs=arguments['inputs'],
agent='BrowsingAgent',
inputs=arguments,
)
# ================================================
@@ -247,7 +238,6 @@ def get_tools(
create_cmd_run_tool(use_simplified_description=use_simplified_tool_desc),
ThinkTool,
FinishTool,
DelegateTool,
]
if codeact_enable_browsing:
tools.append(WebReadTool)

View File

@@ -1,23 +0,0 @@
from litellm import ChatCompletionToolParam
DelegateTool = ChatCompletionToolParam(
type='function',
function={
'name': 'delegate',
'description': 'Delegate a task to another agent.',
'parameters': {
'type': 'object',
'properties': {
'agent': {
'type': 'string',
'description': 'The name of the agent to delegate to.',
},
'inputs': {
'type': 'object',
'description': 'The inputs to pass to the agent.',
},
},
'required': ['agent', 'inputs'],
},
},
)

View File

@@ -111,12 +111,13 @@ class State:
get_conversation_agent_state_filename(sid, user_id), encoded
)
# see if state is in old directory. If yes, delete it.
filename = get_conversation_agent_state_filename(sid)
try:
file_store.delete(filename)
except Exception:
pass
# see if state is in the old directory on saas/remote use cases and delete it.
if user_id:
filename = get_conversation_agent_state_filename(sid)
try:
file_store.delete(filename)
except Exception:
pass
except Exception as e:
logger.error(f'Failed to save state to session: {e}')
raise e
@@ -125,6 +126,11 @@ class State:
def restore_from_session(
sid: str, file_store: FileStore, user_id: str | None = None
) -> 'State':
"""
Restores the state from the previously saved session.
"""
state: State
try:
encoded = file_store.read(
get_conversation_agent_state_filename(sid, user_id)
@@ -132,12 +138,17 @@ class State:
pickled = base64.b64decode(encoded)
state = pickle.loads(pickled)
except FileNotFoundError:
# if user_id is provided, we are in a saas/remote use case
# and we need to check if the state is in the old directory.
if user_id:
# see if state is in old directory. If yes, load it.
filename = get_conversation_agent_state_filename(sid)
encoded = file_store.read(filename)
pickled = base64.b64decode(encoded)
state = pickle.loads(pickled)
else:
raise FileNotFoundError(
f'Could not restore state from session file for sid: {sid}'
)
except Exception as e:
logger.debug(f'Could not restore state from session: {e}')
raise e

View File

@@ -406,7 +406,7 @@ class EventStream:
# Text search in event content if query provided
if query:
event_dict = event_to_dict(event)
event_str = str(event_dict).lower()
event_str = json.dumps(event_dict).lower()
if query.lower() not in event_str:
return True

View File

@@ -121,13 +121,13 @@ Note: OpenHands works best with powerful models like Anthropic's Claude or OpenA
The resolver can automatically attempt to fix a single issue in your repository using the following command:
```bash
python -m openhands.resolver.resolve_issue --repo [OWNER]/[REPO] --issue-number [NUMBER]
python -m openhands.resolver.resolve_issue --selected-repo [OWNER]/[REPO] --issue-number [NUMBER]
```
For instance, if you want to resolve issue #100 in this repo, you would run:
```bash
python -m openhands.resolver.resolve_issue --repo all-hands-ai/openhands --issue-number 100
python -m openhands.resolver.resolve_issue --selected-repo all-hands-ai/openhands --issue-number 100
```
The output will be written to the `output/` directory.
@@ -135,19 +135,19 @@ The output will be written to the `output/` directory.
If you've installed the package from source using poetry, you can use:
```bash
poetry run python openhands/resolver/resolve_issue.py --repo all-hands-ai/openhands --issue-number 100
poetry run python openhands/resolver/resolve_issue.py --selected-repo all-hands-ai/openhands --issue-number 100
```
For resolving multiple issues at once (e.g., in a batch process), you can use the `resolve_all_issues` command:
```bash
python -m openhands.resolver.resolve_all_issues --repo [OWNER]/[REPO] --issue-numbers [NUMBERS]
python -m openhands.resolver.resolve_all_issues --selected-repo [OWNER]/[REPO] --issue-numbers [NUMBERS]
```
For example:
```bash
python -m openhands.resolver.resolve_all_issues --repo all-hands-ai/openhands --issue-numbers 100,101,102
python -m openhands.resolver.resolve_all_issues --selected-repo all-hands-ai/openhands --issue-numbers 100,101,102
```
## Responding to PR Comments

View File

@@ -234,7 +234,7 @@ def main() -> None:
description='Resolve multiple issues from Github or Gitlab.'
)
parser.add_argument(
'--repo',
'--selected-repo',
type=str,
required=True,
help='Github or Gitlab repository to resolve issues in form of `owner/repo`.',
@@ -333,7 +333,7 @@ def main() -> None:
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
)
owner, repo = my_args.repo.split('/')
owner, repo = my_args.selected_repo.split('/')
token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
username = my_args.username if my_args.username else os.getenv('GIT_USERNAME')
if not username:
@@ -342,7 +342,7 @@ def main() -> None:
if not token:
raise ValueError('Token is required.')
platform = identify_token(token)
platform = identify_token(token, my_args.selected_repo)
if platform == Platform.INVALID:
raise ValueError('Token is invalid.')

View File

@@ -539,7 +539,7 @@ def main() -> None:
parser = argparse.ArgumentParser(description='Resolve a single issue.')
parser.add_argument(
'--repo',
'--selected-repo',
type=str,
required=True,
help='repository to resolve issues in form of `owner/repo`.',
@@ -638,9 +638,9 @@ def main() -> None:
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
)
parts = my_args.repo.rsplit('/', 1)
parts = my_args.selected_repo.rsplit('/', 1)
if len(parts) < 2:
raise ValueError('Invalid repo name')
raise ValueError('Invalid repository format. Expected owner/repo')
owner, repo = parts
token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
@@ -651,7 +651,7 @@ def main() -> None:
if not token:
raise ValueError('Token is required.')
platform = identify_token(token, repo)
platform = identify_token(token, my_args.selected_repo)
if platform == Platform.INVALID:
raise ValueError('Token is invalid.')

View File

@@ -22,13 +22,13 @@ class Platform(Enum):
GITLAB = 2
def identify_token(token: str, repo: str | None = None) -> Platform:
def identify_token(token: str, selected_repo: str | None = None) -> Platform:
"""
Identifies whether a token belongs to GitHub or GitLab.
Parameters:
token (str): The personal access token to check.
repo (str): Repository in format "owner/repo" for GitHub Actions token validation.
selected_repo (str): Repository in format "owner/repo" for GitHub Actions token validation.
Returns:
Platform: "GitHub" if the token is valid for GitHub,
@@ -36,8 +36,8 @@ def identify_token(token: str, repo: str | None = None) -> Platform:
"Invalid" if the token is not recognized by either.
"""
# Try GitHub Actions token format (Bearer) with repo endpoint if repo is provided
if repo:
github_repo_url = f'https://api.github.com/repos/{repo}'
if selected_repo:
github_repo_url = f'https://api.github.com/repos/{selected_repo}'
github_bearer_headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github+json',
@@ -50,7 +50,7 @@ def identify_token(token: str, repo: str | None = None) -> Platform:
if github_repo_response.status_code == 200:
return Platform.GITHUB
except requests.RequestException as e:
print(f'Error connecting to GitHub API (repo check): {e}')
print(f'Error connecting to GitHub API (selected_repo check): {e}')
# Try GitHub PAT format (token)
github_url = 'https://api.github.com/user'

View File

@@ -330,6 +330,15 @@ class StandaloneConversationManager(ConversationManager):
session = self._local_agent_loops_by_sid.get(sid)
if session:
# Check if the session is ready to process actions
if not session.is_ready():
logger.info(
f'Session not ready, queueing action: {data}',
extra={'session_id': sid},
)
session.queue_action(data)
return
await session.dispatch(data)
return

View File

@@ -1,7 +1,9 @@
import asyncio
import time
from copy import deepcopy
from dataclasses import field
from logging import LoggerAdapter
from typing import List
import socketio
@@ -12,6 +14,7 @@ from openhands.core.config.condenser_config import (
)
from openhands.core.const.guide_url import TROUBLESHOOTING_URL
from openhands.core.logger import OpenHandsLoggerAdapter
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import AgentState
from openhands.events.action import MessageAction, NullAction
from openhands.events.event import Event, EventSource
@@ -43,6 +46,9 @@ class Session:
file_store: FileStore
user_id: str | None
logger: LoggerAdapter
_pending_actions: List[dict] = []
_is_ready: bool = False
_ready_event: asyncio.Event = field(default_factory=asyncio.Event)
def __init__(
self,
@@ -70,6 +76,16 @@ class Session:
self.config = deepcopy(config)
self.loop = asyncio.get_event_loop()
self.user_id = user_id
self._pending_actions = []
self._is_ready = False
self._ready_event = asyncio.Event()
# Subscribe to agent state changes to detect when the agent is ready
self.agent_session.event_stream.subscribe(
EventStreamSubscriber.SERVER,
self._on_agent_state_change,
f'{self.sid}_state_change',
)
async def close(self):
if self.sio:
@@ -86,6 +102,11 @@ class Session:
async def initialize_agent(
self, settings: Settings, initial_message: MessageAction | None
):
# Reset the ready state when initializing a new agent
self._is_ready = False
self._ready_event.clear()
# Set the agent state to LOADING
self.agent_session.event_stream.add_event(
AgentStateChangedObservation('', AgentState.LOADING),
EventSource.ENVIRONMENT,
@@ -279,3 +300,55 @@ class Session:
asyncio.run_coroutine_threadsafe(
self._send_status_message(msg_type, id, message), self.loop
)
def is_ready(self) -> bool:
"""Check if the session is ready to process actions."""
return self._is_ready
def queue_action(self, action_data: dict):
"""Queue an action to be processed when the session is ready."""
logger.info(f'Queueing action for session {self.sid}: {action_data}')
self._pending_actions.append(action_data)
# Start a task to process the queue when the session becomes ready
asyncio.run_coroutine_threadsafe(self._process_queue_when_ready(), self.loop)
async def _process_queue_when_ready(self):
"""Process the queue of actions when the session becomes ready."""
if not self._ready_event.is_set():
try:
# Wait for the session to become ready
await asyncio.wait_for(self._ready_event.wait(), timeout=60)
except asyncio.TimeoutError:
logger.warning(
f'Timeout waiting for session {self.sid} to become ready'
)
return
# Process all pending actions
if self._pending_actions:
logger.info(
f'Processing {len(self._pending_actions)} queued actions for session {self.sid}'
)
# Process all pending actions
for action_data in self._pending_actions:
logger.info(f'Processing queued action: {action_data}')
await self.dispatch(action_data)
# Clear the queue
self._pending_actions = []
def _on_agent_state_change(self, event: Event):
"""Handle agent state change events to detect when the agent is ready."""
if isinstance(event, AgentStateChangedObservation):
# Check if the agent state indicates it's ready
if event.agent_state in ['idle', 'ready']:
logger.info(f'Agent for session {self.sid} is now ready')
self._is_ready = True
self._ready_event.set()
# Process any pending actions
asyncio.run_coroutine_threadsafe(
self._process_queue_when_ready(), self.loop
)

View File

@@ -1,339 +0,0 @@
import asyncio
import os
import pytest
from litellm.types.utils import ModelResponse
from openhands.agenthub.browsing_agent.browsing_agent import BrowsingAgent
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.controller.agent_controller import AgentController
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig, LLMConfig
from openhands.core.message import Message, TextContent
from openhands.events import EventSource, EventStream
from openhands.events.action import (
AgentDelegateAction,
AgentFinishAction,
MessageAction,
)
from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.storage.memory import InMemoryFileStore
class MockLLM(LLM):
"""Base class for mock LLMs used in testing."""
def __init__(self, config: LLMConfig, completion_response: dict):
super().__init__(config)
self._completion_response = completion_response
self._function_calling_active = True
self.metrics = Metrics()
def _completion(self, **kwargs) -> dict:
return self._completion_response
def vision_is_active(self) -> bool:
return False
def is_caching_prompt_active(self) -> bool:
return False
def format_messages_for_llm(self, messages: list) -> list:
return messages
def _post_completion(self, response: ModelResponse) -> float:
return 0.0
@pytest.fixture
def mock_llm():
"""Creates a mock LLM for testing."""
completion_response = {
'choices': [
{
'message': {
'role': 'assistant',
'content': "I'll help with that task.",
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'delegate',
'arguments': '{"agent": "BrowsingAgent", "inputs": {"task": "search for OpenHands repository"}}',
},
}
],
}
}
]
}
return MockLLM(LLMConfig(), completion_response)
@pytest.fixture
def mock_browsing_llm():
"""Creates a mock LLM for the browsing agent."""
completion_response = {
'choices': [
{
'message': {
'role': 'assistant',
'content': "I've completed the search task.",
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'finish',
'arguments': '{"message": "Found the repository at github.com/All-Hands-AI/OpenHands", "task_completed": "true"}',
},
}
],
}
}
]
}
return MockLLM(LLMConfig(), completion_response)
@pytest.fixture
def mock_writer_llm():
"""Creates a mock LLM for the writer CodeAct agent."""
completion_response = {
'choices': [
{
'message': {
'role': 'assistant',
'content': "I'll help with that task.",
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'delegate',
'arguments': '{"agent": "CodeActAgent", "inputs": {"task": "analyze the code in /workspace/example.py"}}',
},
}
],
}
}
]
}
return MockLLM(LLMConfig(), completion_response)
@pytest.fixture
def mock_reader_llm():
"""Creates a mock LLM for the reader CodeAct agent."""
completion_response = {
'choices': [
{
'message': {
'role': 'assistant',
'content': "I've analyzed the code.",
'tool_calls': [
{
'id': 'call_1',
'type': 'function',
'function': {
'name': 'finish',
'arguments': '{"message": "The code has been analyzed. It contains a simple function.", "task_completed": "true"}',
},
}
],
}
}
]
}
return MockLLM(LLMConfig(), completion_response)
@pytest.mark.asyncio
async def test_codeact_to_browsing_delegation(mock_llm, mock_browsing_llm):
"""
Test delegation from CodeAct agent to BrowsingAgent.
This test verifies that:
1. CodeAct agent can delegate tasks to BrowsingAgent
2. BrowsingAgent can receive and process the delegated task
3. The delegation flow works end-to-end with proper state management
"""
# Setup event stream
sid = 'test-delegation'
file_store = InMemoryFileStore({})
event_stream = EventStream(sid=sid, file_store=file_store)
# Create parent CodeAct agent
parent_config = AgentConfig()
parent_config.codeact_enable_browsing = (
True # Enable browsing to allow delegation to BrowsingAgent
)
parent_agent = CodeActAgent(mock_llm, parent_config)
parent_state = State(max_iterations=10)
parent_controller = AgentController(
agent=parent_agent,
event_stream=event_stream,
max_iterations=10,
sid='parent',
confirmation_mode=False,
headless_mode=True,
initial_state=parent_state,
)
# Create child BrowsingAgent
child_config = AgentConfig()
child_agent = BrowsingAgent(mock_browsing_llm, child_config)
child_state = State(max_iterations=10)
# Note: We don't need to store the child_controller since it's managed by the parent's delegate
AgentController(
agent=child_agent,
event_stream=event_stream,
max_iterations=10,
sid='child',
confirmation_mode=False,
headless_mode=True,
initial_state=child_state,
)
# Simulate a user message to trigger delegation
message = Message(
role='user',
content=[TextContent(text='Please search for the OpenHands repository')],
)
message_action = MessageAction(content=message.content[0].text)
message_action._source = EventSource.USER
# Process the message
await parent_controller._on_event(message_action)
await asyncio.sleep(0.5) # Give time for processing
# Verify delegation occurred
events = list(event_stream.get_events())
delegate_actions = [e for e in events if isinstance(e, AgentDelegateAction)]
assert len(delegate_actions) == 1, 'Expected one delegation action'
delegate_action = delegate_actions[0]
assert delegate_action.agent == 'BrowsingAgent'
assert 'search' in str(delegate_action.inputs)
# Verify parent has a delegate controller
assert parent_controller.delegate is not None
assert parent_controller.delegate.agent.name == 'BrowsingAgent'
# Let the child agent process its task
child_message = Message(
role='user', content=[TextContent(text=str(delegate_action.inputs))]
)
child_message_action = MessageAction(content=child_message.content[0].text)
child_message_action._source = EventSource.USER
await parent_controller.delegate._on_event(child_message_action)
await asyncio.sleep(0.5)
# Verify child completed its task
events = list(event_stream.get_events())
finish_actions = [e for e in events if isinstance(e, AgentFinishAction)]
assert len(finish_actions) == 1, 'Expected one finish action'
# Verify parent's delegate is cleared after child finishes
assert parent_controller.delegate is None
# Cleanup
await parent_controller.close()
@pytest.mark.asyncio
async def test_codeact_to_codeact_delegation(mock_writer_llm, mock_reader_llm):
"""
Test delegation between two CodeAct agents, where one is in read-only mode.
This test verifies that:
1. A CodeAct agent can delegate tasks to another CodeAct agent
2. The reader CodeAct agent can operate in read-only mode
3. The delegation flow works end-to-end with proper state management
"""
# Setup event stream
sid = 'test-codeact-delegation'
file_store = InMemoryFileStore({})
event_stream = EventStream(sid=sid, file_store=file_store)
# Create example.py for testing
os.makedirs('/workspace', exist_ok=True)
with open('/workspace/example.py', 'w') as f:
f.write('def hello():\n print("Hello, World!")\n')
# Create parent CodeAct agent with full capabilities
parent_config = AgentConfig()
parent_config.codeact_enable_jupyter = True
parent_config.codeact_enable_llm_editor = True
parent_config.codeact_enable_browsing = True # Enable browsing to allow delegation
parent_agent = CodeActAgent(mock_writer_llm, parent_config)
parent_state = State(max_iterations=10)
parent_controller = AgentController(
agent=parent_agent,
event_stream=event_stream,
max_iterations=10,
sid='parent',
confirmation_mode=False,
headless_mode=True,
initial_state=parent_state,
)
# Create child CodeAct agent in read-only mode
child_config = AgentConfig()
child_config.codeact_enable_jupyter = True # Enable Python execution
child_config.codeact_enable_llm_editor = False # Disable file editing
child_agent = CodeActAgent(mock_reader_llm, child_config)
child_state = State(max_iterations=10)
# Note: We don't need to store the child_controller since it's managed by the parent's delegate
AgentController(
agent=child_agent,
event_stream=event_stream,
max_iterations=10,
sid='child',
confirmation_mode=False,
headless_mode=True,
initial_state=child_state,
)
# Simulate a user message to trigger delegation
message = Message(
role='user', content=[TextContent(text='Please analyze the code in example.py')]
)
message_action = MessageAction(content=message.content[0].text)
message_action._source = EventSource.USER
# Process the message
await parent_controller._on_event(message_action)
await asyncio.sleep(0.5) # Give time for processing
# Verify delegation occurred
events = list(event_stream.get_events())
delegate_actions = [e for e in events if isinstance(e, AgentDelegateAction)]
assert len(delegate_actions) == 1, 'Expected one delegation action'
delegate_action = delegate_actions[0]
assert delegate_action.agent == 'CodeActAgent'
assert 'analyze' in str(delegate_action.inputs)
# Verify parent has a delegate controller
assert parent_controller.delegate is not None
assert parent_controller.delegate.agent.name == 'CodeActAgent'
# Let the child agent process its task
child_message = Message(
role='user', content=[TextContent(text=str(delegate_action.inputs))]
)
child_message_action = MessageAction(content=child_message.content[0].text)
child_message_action._source = EventSource.USER
await parent_controller.delegate._on_event(child_message_action)
await asyncio.sleep(0.5)
# Verify child completed its task
events = list(event_stream.get_events())
finish_actions = [e for e in events if isinstance(e, AgentFinishAction)]
assert len(finish_actions) == 1, 'Expected one finish action'
# Verify parent's delegate is cleared after child finishes
assert parent_controller.delegate is None
# Cleanup
await parent_controller.close()
os.remove('/workspace/example.py')