Compare commits

..

25 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
Xingyao Wang
e4ccd4057d misc: tweak frontend prompt to prevent agent push to a different branch & update app prompt (#7357) 2025-03-20 05:09:51 +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 318 additions and 61 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

@@ -40,7 +40,7 @@ export function ActionSuggestions({
suggestion={{
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request.",
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
@@ -51,7 +51,7 @@ export function ActionSuggestions({
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request.",
"Please push the changes to GitHub and open a pull request. Please use the exact SAME branch name as the one you are currently on.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");

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

@@ -19,7 +19,7 @@ each of which has a corresponding port:
When starting a web server, use the corresponding ports. You should also
set any options to allow iframes and CORS requests, and allow the server to
be accessed from any host (e.g. 0.0.0.0).
For example, if you are using vite.config.js, you should set server.host to 0.0.0.0, server.port to the port assigned to you, and allowedHosts to ['*'].
For example, if you are using vite.config.js, you should set server.host to 0.0.0.0, server.port to the port assigned to you, and allowedHosts to the host assigned to you.
{% endif %}
{% if runtime_info.additional_agent_instructions %}
{{ runtime_info.additional_agent_instructions }}

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

@@ -99,6 +99,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -127,6 +128,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"