mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
24 Commits
openhands-
...
allow-mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a2e7d6270 | ||
|
|
f5c58adaf7 | ||
|
|
c6cb025afe | ||
|
|
b0030d3a2b | ||
|
|
d76477099c | ||
|
|
3e3b2aaa5c | ||
|
|
1f8aa93843 | ||
|
|
34920ea04e | ||
|
|
f5aeb47a72 | ||
|
|
c830177207 | ||
|
|
cb8214676e | ||
|
|
fdfb7308b8 | ||
|
|
4785de91b0 | ||
|
|
effd2b7d06 | ||
|
|
608dd8f2c2 | ||
|
|
6d0c03509e | ||
|
|
3e1070bbe9 | ||
|
|
2045350720 | ||
|
|
5f83d4cf9a | ||
|
|
d5a996a9e1 | ||
|
|
b0d38bbeb8 | ||
|
|
ed50b3ee8f | ||
|
|
4e5ed36213 | ||
|
|
9060452af6 |
6
.github/workflows/openhands-resolver.yml
vendored
6
.github/workflows/openhands-resolver.yml
vendored
@@ -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') }}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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连接到您的文件系统"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user