Compare commits

..

2 Commits

Author SHA1 Message Date
Robert Brennan 8a0620b21e Update openhands/server/listen.py 2024-11-08 15:25:24 -05:00
openhands 3356753f79 Add cookie-based GitHub authentication caching
- Add cookie in /authenticate endpoint with 1-hour expiration
- Check for cookie in attach_session middleware before calling GitHub API
- Support cookie auth in WebSocket endpoint
- Maintain backward compatibility with X-GitHub-Token header
2024-11-08 20:23:07 +00:00
49 changed files with 1863 additions and 2148 deletions
+1 -1
View File
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
### 9. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image. Follow these steps:
1. Set the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.13-nikolaik
2. Example: export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.12-nikolaik
## Develop inside Docker container
+3 -3
View File
@@ -38,15 +38,15 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker run -it --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13
docker.all-hands.dev/all-hands-ai/openhands:0.12
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -11,7 +11,7 @@ services:
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
- SANDBOX_API_HOSTNAME=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.13-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.12-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
python -m openhands.core.cli
```
+2 -2
View File
@@ -44,7 +44,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -53,6 +53,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.13 \
docker.all-hands.dev/all-hands-ai/openhands:0.12 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+3 -3
View File
@@ -11,15 +11,15 @@
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.12-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.13
docker.all-hands.dev/all-hands-ai/openhands:0.12
```
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
+2 -4
View File
@@ -35,8 +35,7 @@ def codeact_user_response_eda(state: State) -> str:
# retrieve the latest model message from history
if state.history:
last_agent_message = state.get_last_agent_message()
model_guess = last_agent_message.content if last_agent_message else ''
model_guess = state.get_last_agent_message()
assert game is not None, 'Game is not initialized.'
msg = game.generate_user_response(model_guess)
@@ -141,8 +140,7 @@ def process_instance(
if state is None:
raise ValueError('State should not be None.')
last_agent_message = state.get_last_agent_message()
final_message = last_agent_message.content if last_agent_message else ''
final_message = state.get_last_agent_message()
logger.info(f'Final message: {final_message} | Ground truth: {instance["text"]}')
test_result = game.reward()
+1 -2
View File
@@ -102,8 +102,7 @@ def process_instance(
raise ValueError('State should not be None.')
# retrieve the last message from the agent
last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
model_answer_raw = state.get_last_agent_message()
# attempt to parse model_answer
ast_eval_fn = instance['ast_eval']
-1
View File
@@ -83,7 +83,6 @@ def get_config(instance: pd.Series) -> AppConfig:
timeout=1800,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
remote_runtime_init_timeout=1800,
),
# do not mount workspace
workspace_base=None,
-1
View File
@@ -146,7 +146,6 @@ def get_config(
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_remote_runtime_alive=False,
remote_runtime_init_timeout=1800,
),
# do not mount workspace
workspace_base=None,
+1 -2
View File
@@ -127,8 +127,7 @@ def process_instance(instance: Any, metadata: EvalMetadata, reset_logger: bool =
raise ValueError('State should not be None.')
# retrieve the last message from the agent
last_agent_message = state.get_last_agent_message()
model_answer_raw = last_agent_message.content if last_agent_message else ''
model_answer_raw = state.get_last_agent_message()
# attempt to parse model_answer
correct = eval_answer(str(model_answer_raw), str(answer))
+27 -6
View File
@@ -8,6 +8,7 @@ describe("Cache", () => {
const testTTL = 1000; // 1 second
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers();
});
@@ -15,7 +16,17 @@ describe("Cache", () => {
vi.useRealTimers();
});
it("gets data from memory if not expired", () => {
it("sets data in localStorage with expiration", () => {
cache.set(testKey, testData, testTTL);
const cachedEntry = JSON.parse(
localStorage.getItem(`app_cache_${testKey}`) || "",
);
expect(cachedEntry.data).toEqual(testData);
expect(cachedEntry.expiration).toBeGreaterThan(Date.now());
});
it("gets data from localStorage if not expired", () => {
cache.set(testKey, testData, testTTL);
expect(cache.get(testKey)).toEqual(testData);
@@ -28,6 +39,7 @@ describe("Cache", () => {
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("returns null if cached data is expired", () => {
@@ -35,19 +47,28 @@ describe("Cache", () => {
vi.advanceTimersByTime(testTTL + 1);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("deletes data from memory", () => {
it("deletes data from localStorage", () => {
cache.set(testKey, testData, testTTL);
cache.delete(testKey);
expect(cache.get(testKey)).toBeNull();
expect(localStorage.getItem(`app_cache_${testKey}`)).toBeNull();
});
it("clears all data with the app prefix from memory", () => {
it("clears all data with the app prefix from localStorage", () => {
cache.set(testKey, testData, testTTL);
cache.set("anotherKey", { data: "More data" }, testTTL);
cache.clearAll();
expect(cache.get(testKey)).toBeNull();
expect(cache.get("anotherKey")).toBeNull();
expect(localStorage.length).toBe(0);
});
it("does not retrieve non-prefixed data from localStorage when clearing", () => {
localStorage.setItem("nonPrefixedKey", "should remain");
cache.set(testKey, testData, testTTL);
cache.clearAll();
expect(localStorage.getItem("nonPrefixedKey")).toBe("should remain");
});
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"dependencies": {
"@monaco-editor/react": "^4.6.0",
"@nextui-org/react": "^2.4.8",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.13.0",
"version": "0.12.3",
"private": true,
"type": "module",
"engines": {
@@ -120,4 +120,4 @@
"public"
]
}
}
}
-6
View File
@@ -174,12 +174,6 @@ class OpenHands {
true,
);
}
static async logout(): Promise<Response> {
return request(`/api/logout`, {
method: "POST",
});
}
}
export default OpenHands;
@@ -11,7 +11,6 @@ import { clientAction as settingsClientAction } from "#/routes/settings";
import { clientAction as loginClientAction } from "#/routes/login";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { logout } from "#/services/auth";
interface AccountSettingsModalProps {
onClose: () => void;
@@ -88,17 +87,6 @@ function AccountSettingsModal({
type="password"
defaultValue={data?.ghToken ?? ""}
/>
<span className="text-sm">
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$GET_YOUR_TOKEN)}{" "}
<a
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
rel="noreferrer noopener"
className="text-[#791B80] underline"
>
{t(I18nKey.CONNECT_TO_GITHUB_MODAL$HERE)}
</a>
</span>
{gitHubError && (
<p className="text-danger text-xs">
{t(I18nKey.ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID)}
@@ -109,7 +97,10 @@ function AccountSettingsModal({
variant="text-like"
text={t(I18nKey.ACCOUNT_SETTINGS_MODAL$DISCONNECT)}
onClick={() => {
logout();
settingsFetcher.submit(
{},
{ method: "POST", action: "/logout" },
);
onClose();
}}
className="text-danger self-start"
@@ -43,7 +43,10 @@ export function ProjectMenuCard({
posthog.capture("push_to_github_button_clicked");
const rawEvent = {
content: `
Please push the changes to GitHub and open a pull request.
Let's push the code to GitHub.
If we're currently on the openhands-workspace branch, please create a new branch with a descriptive name.
Commit any changes and push them to the remote repository.
Finally, open up a pull request using the GitHub API and the token in the GITHUB_TOKEN environment variable, then show me the URL of the pull request.
`,
imageUrls: [],
timestamp: new Date().toISOString(),
+4 -7
View File
@@ -1531,12 +1531,10 @@
"tr": "Özel"
},
"ERROR_MESSAGE$SHOW_DETAILS": {
"en": "Show details",
"es": "Mostrar detalles"
"en": "Show details"
},
"ERROR_MESSAGE$HIDE_DETAILS": {
"en": "Hide details",
"es": "Ocultar detalles"
"en": "Hide details"
},
"STATUS$STARTING_RUNTIME": {
"en": "Starting Runtime...",
@@ -1618,7 +1616,7 @@
},
"ACCOUNT_SETTINGS_MODAL$CLOSE":{
"en": "Close",
"es": "Cerrar"
"es": ""
},
"ACCOUNT_SETTINGS_MODAL$GITHUB_TOKEN_INVALID":{
"en": "GitHub token is invalid. Please try again.",
@@ -1737,8 +1735,7 @@
"es":"atrás"
},
"STATUS$ERROR_LLM_AUTHENTICATION": {
"en": "Error authenticating with the LLM provider. Please check your API key",
"es": "Error autenticando con el proveedor de LLM. Por favor revisa tu API key"
"en": "Error authenticating with the LLM provider. Please check your API key"
},
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
"en": "There was an error while connecting to the runtime. Please refresh the page."
+13 -3
View File
@@ -27,7 +27,6 @@ import { getSettings, settingsAreUpToDate } from "#/services/settings";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import NewProjectIcon from "#/assets/new-project.svg?react";
import DocsIcon from "#/assets/docs.svg?react";
import { logout } from "#/services/auth";
import { userIsAuthenticated } from "#/utils/user-is-authenticated";
import { generateGitHubAuthUrl } from "#/utils/generate-github-auth-url";
import { WaitlistModal } from "#/components/waitlist-modal";
@@ -149,6 +148,7 @@ export default function MainApp() {
settings,
analyticsConsent,
} = useLoaderData<typeof clientLoader>();
const logoutFetcher = useFetcher({ key: "logout" });
const endSessionFetcher = useFetcher({ key: "end-session" });
const dispatch = useDispatch();
@@ -210,11 +210,21 @@ export default function MainApp() {
}
}, [location.pathname]);
const handleUserLogout = () => {
logoutFetcher.submit(
{},
{
method: "POST",
action: "/logout",
},
);
};
const handleAccountSettingsModalClose = () => {
// If the user closes the modal without connecting to GitHub,
// we need to log them out to clear the invalid token from the
// local storage
if (isGitHubErrorReponse(user)) logout();
if (isGitHubErrorReponse(user)) handleUserLogout();
setAccountSettingsModalOpen(false);
};
@@ -256,7 +266,7 @@ export default function MainApp() {
? { avatar_url: user.avatar_url }
: undefined
}
onLogout={logout}
onLogout={handleUserLogout}
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
/>
<button
+13
View File
@@ -0,0 +1,13 @@
import { json } from "@remix-run/react";
import posthog from "posthog-js";
import { cache } from "#/utils/cache";
export const clientAction = () => {
const ghToken = localStorage.getItem("ghToken");
if (ghToken) localStorage.removeItem("ghToken");
cache.clearAll();
posthog.reset();
return json({ success: true });
};
-11
View File
@@ -4,7 +4,6 @@ import toast from "#/utils/toast";
const WAIT_FOR_AUTH_DELAY_MS = 500;
const UNAUTHED_ROUTE_PREFIXES = [
"/api/logout",
"/api/authenticate",
"/api/options/",
"/config.json",
@@ -64,16 +63,6 @@ export async function request(
} catch (e) {
onFail(`Error fetching ${url}`);
}
if (response?.status === 401) {
await request(
"/api/authenticate",
{
method: "POST",
},
true,
);
return request(url, options, disableToast, returnResponse, maxRetries - 1);
}
if (response?.status && response?.status >= 400) {
onFail(
`${response.status} error while fetching ${url}: ${response?.statusText}`,
-16
View File
@@ -1,11 +1,5 @@
import posthog from "posthog-js";
import { cache } from "#/utils/cache";
import OpenHands from "#/api/open-hands";
const TOKEN_KEY = "token";
const GITHUB_TOKEN_KEY = "ghToken";
const REPO_KEY = "repo";
const getToken = (): string => localStorage.getItem(TOKEN_KEY) ?? "";
@@ -28,15 +22,6 @@ const clearGitHubToken = (): void => {
localStorage.removeItem(GITHUB_TOKEN_KEY);
};
const logout = (): void => {
clearToken();
clearGitHubToken();
localStorage.removeItem(REPO_KEY);
cache.clearAll();
posthog.reset();
OpenHands.logout();
};
export {
getToken,
setToken,
@@ -44,5 +29,4 @@ export {
getGitHubToken,
setGitHubToken,
clearGitHubToken,
logout,
};
+24 -15
View File
@@ -5,17 +5,26 @@ type CacheEntry<T> = {
};
class Cache {
private prefix = "app_cache_";
private defaultTTL = 5 * 60 * 1000; // 5 minutes
private cacheMemory: Record<string, string> = {};
/**
* Generate a unique key with prefix for local storage
* @param key The key to be stored in local storage
* @returns The unique key with prefix
*/
private getKey(key: CacheKey): string {
return `${this.prefix}${key}`;
}
/**
* Retrieve the cached data from memory
* @param key The key to be retrieved from memory
* @returns The data stored in memory
* Retrieve the cached data from local storage
* @param key The key to be retrieved from local storage
* @returns The data stored in local storage
*/
public get<T>(key: CacheKey): T | null {
const cachedEntry = this.cacheMemory[key];
const cachedEntry = localStorage.getItem(this.getKey(key));
if (cachedEntry) {
const { data, expiration } = JSON.parse(cachedEntry) as CacheEntry<T>;
if (Date.now() < expiration) return data;
@@ -26,34 +35,34 @@ class Cache {
}
/**
* Store the data in memory with expiration
* @param key The key to be stored in memory
* @param data The data to be stored in memory
* Store the data in local storage with expiration
* @param key The key to be stored in local storage
* @param data The data to be stored in local storage
* @param ttl The time to live for the data in milliseconds
* @returns void
*/
public set<T>(key: CacheKey, data: T, ttl = this.defaultTTL): void {
const expiration = Date.now() + ttl;
const entry: CacheEntry<T> = { data, expiration };
this.cacheMemory[key] = JSON.stringify(entry);
localStorage.setItem(this.getKey(key), JSON.stringify(entry));
}
/**
* Remove the data from memory
* @param key The key to be removed from memory
* Remove the data from local storage
* @param key The key to be removed from local storage
* @returns void
*/
public delete(key: CacheKey): void {
delete this.cacheMemory[key];
localStorage.removeItem(this.getKey(key));
}
/**
* Clear all data
* Clear all data with the app prefix from local storage
* @returns void
*/
public clearAll(): void {
Object.keys(this.cacheMemory).forEach((key) => {
delete this.cacheMemory[key];
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(this.prefix)) localStorage.removeItem(key);
});
}
}
@@ -39,6 +39,7 @@ from openhands.runtime.plugins import (
JupyterRequirement,
PluginRequirement,
)
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager
@@ -85,6 +86,16 @@ class CodeActAgent(Agent):
super().__init__(llm, config)
self.reset()
self.micro_agent = (
MicroAgent(
os.path.join(
os.path.dirname(__file__), 'micro', f'{config.micro_agent_name}.md'
)
)
if config.micro_agent_name
else None
)
self.function_calling_active = self.config.function_calling
if self.function_calling_active and not self.llm.is_function_calling_active():
logger.warning(
@@ -94,6 +105,7 @@ class CodeActAgent(Agent):
self.function_calling_active = False
if self.function_calling_active:
# Function calling mode
self.tools = codeact_function_calling.get_tools(
codeact_enable_browsing=self.config.codeact_enable_browsing,
codeact_enable_jupyter=self.config.codeact_enable_jupyter,
@@ -102,17 +114,18 @@ class CodeActAgent(Agent):
logger.debug(
f'TOOLS loaded for CodeActAgent: {json.dumps(self.tools, indent=2)}'
)
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'tools'),
)
self.system_prompt = codeact_function_calling.SYSTEM_PROMPT
self.initial_user_message = None
else:
# Non-function-calling mode
self.action_parser = CodeActResponseParser()
self.prompt_manager = PromptManager(
microagent_dir=os.path.join(os.path.dirname(__file__), 'micro'),
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts', 'default'),
prompt_dir=os.path.join(os.path.dirname(__file__)),
agent_skills_docs=AgentSkillsRequirement.documentation,
micro_agent=self.micro_agent,
)
self.system_prompt = self.prompt_manager.system_message
self.initial_user_message = self.prompt_manager.initial_user_message
self.pending_actions: deque[Action] = deque()
@@ -324,8 +337,8 @@ class CodeActAgent(Agent):
return self.pending_actions.popleft()
# if we're done, go back
latest_user_message = state.get_last_user_message()
if latest_user_message and latest_user_message.content.strip() == '/exit':
last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.strip() == '/exit':
return AgentFinishAction()
# prepare what we want to send to the LLM
@@ -390,19 +403,17 @@ class CodeActAgent(Agent):
role='system',
content=[
TextContent(
text=self.prompt_manager.get_system_message(),
cache_prompt=self.llm.is_caching_prompt_active(),
text=self.system_prompt,
cache_prompt=self.llm.is_caching_prompt_active(), # Cache system prompt
)
],
)
]
example_message = self.prompt_manager.get_example_user_message()
if example_message:
if self.initial_user_message:
messages.append(
Message(
role='user',
content=[TextContent(text=example_message)],
cache_prompt=self.llm.is_caching_prompt_active(),
content=[TextContent(text=self.initial_user_message)],
)
)
@@ -451,9 +462,8 @@ class CodeActAgent(Agent):
pending_tool_call_action_messages.pop(response_id)
for message in messages_to_add:
# add regular message
if message:
if message.role == 'user':
self.prompt_manager.enhance_message(message)
# handle error if the message is the SAME role as the previous message
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
# there shouldn't be two consecutive messages from the same role
@@ -483,6 +493,23 @@ class CodeActAgent(Agent):
break
if not self.function_calling_active:
self.prompt_manager.add_turns_left_reminder(messages, state)
# The latest user message is important:
# we want to remind the agent of the environment constraints
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
)
# do not add this for function calling
if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
return messages
@@ -25,6 +25,14 @@ from openhands.events.action import (
)
from openhands.events.tool import ToolCallMetadata
SYSTEM_PROMPT = """You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>
"""
_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.
@@ -1,9 +0,0 @@
---
name: flarglebargle
agent: CodeActAgent
triggers:
- flarglebargle
---
IMPORTANT! The user has said the magic word "flarglebargle". You must
only respond with a message telling them how smart they are
@@ -1,31 +1,69 @@
---
name: github
agent: CodeActAgent
triggers:
- github
- git
require_env_var:
SANDBOX_ENV_GITHUB_TOKEN: "Create a GitHub Personal Access Token (https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and set it as SANDBOX_GITHUB_TOKEN in your environment variables."
---
You have access to an environment variable, `GITHUB_TOKEN`, which allows you to interact with
the GitHub API.
# How to Interact with Github
You can use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API.
ALWAYS use the GitHub API for operations instead of a web browser.
## Environment Variable Available
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch called `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitHub API to create a pull request, if you haven't already
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a pull request, send the user a short message with a link to the pull request.
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git checkout -b create-widget
git add .
git commit -m "Create widget"
git push origin create-widget
curl -X POST "https://api.github.com/repos/CodeActOrg/openhands/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
- `GITHUB_TOKEN`: A read-only token for Github.
## Using GitHub's RESTful API
Use `curl` with the `GITHUB_TOKEN` to interact with GitHub's API. Here are some common operations:
Here's a template for API calls:
```sh
curl -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/{endpoint}"
```
First replace `{endpoint}` with the specific API path. Common operations:
1. View an issue or pull request:
- Issues: `/repos/{owner}/{repo}/issues/{issue_number}`
- Pull requests: `/repos/{owner}/{repo}/pulls/{pull_request_number}`
2. List repository issues or pull requests:
- Issues: `/repos/{owner}/{repo}/issues`
- Pull requests: `/repos/{owner}/{repo}/pulls`
3. Search issues or pull requests:
- `/search/issues?q=repo:{owner}/{repo}+is:{type}+{search_term}+state:{state}`
- Replace `{type}` with `issue` or `pr`
4. List repository branches:
`/repos/{owner}/{repo}/branches`
5. Get commit details:
`/repos/{owner}/{repo}/commits/{commit_sha}`
6. Get repository details:
`/repos/{owner}/{repo}`
7. Get user information:
`/user`
8. Search repositories:
`/search/repositories?q={query}`
9. Get rate limit status:
`/rate_limit`
Replace `{owner}`, `{repo}`, `{commit_sha}`, `{issue_number}`, `{pull_request_number}`,
`{search_term}`, `{state}`, and `{query}` with appropriate values.
## Important Notes
1. Always use the GitHub API for operations instead of a web browser.
2. The `GITHUB_TOKEN` is read-only. Avoid operations that require write access.
3. Git config (username and email) is pre-set. Do not modify.
4. Edit and test code locally. Never push directly to remote.
5. Verify correct branch before committing.
6. Commit changes frequently.
7. If the issue or task is ambiguous or lacks sufficient detail, always request clarification from the user before proceeding.
8. You should avoid using command line tools like `sed` for file editing.
@@ -1,7 +0,0 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>
@@ -215,5 +215,12 @@ The server is running on port 5000 with PID 126. You can access the list of numb
{% endset %}
Here is an example of how you can interact with the environment for task solving:
{{ DEFAULT_EXAMPLE }}
{% if micro_agent %}
--- BEGIN OF GUIDELINE ---
The following information may assist you in completing your task:
{{ micro_agent }}
--- END OF GUIDELINE ---
{% endif %}
NOW, LET'S START!
@@ -155,7 +155,7 @@ class CodeActSWEAgent(Agent):
"""
# if we're done, go back
last_user_message = state.get_last_user_message()
if last_user_message and last_user_message.content.strip() == '/exit':
if last_user_message and last_user_message.strip() == '/exit':
return AgentFinishAction()
# prepare what we want to send to the LLM
+4 -4
View File
@@ -156,14 +156,14 @@ class State:
return last_user_message, last_user_message_image_urls
def get_last_agent_message(self) -> MessageAction | None:
def get_last_agent_message(self) -> str | None:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
return event
return event.content
return None
def get_last_user_message(self) -> MessageAction | None:
def get_last_user_message(self) -> str | None:
for event in reversed(self.history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return event
return event.content
return None
-2
View File
@@ -69,7 +69,6 @@ class AppConfig:
file_uploads_max_file_size_mb: int = 0
file_uploads_restrict_file_types: bool = False
file_uploads_allowed_extensions: list[str] = field(default_factory=lambda: ['.*'])
runloop_api_key: str | None = None
defaults_dict: ClassVar[dict] = {}
@@ -140,7 +139,6 @@ class AppConfig:
'jwt_secret',
'modal_api_token_id',
'modal_api_token_secret',
'runloop_api_key',
]:
attr_value = '******' if attr_value else None
+1 -3
View File
@@ -14,8 +14,7 @@ class SandboxConfig:
base_container_image: The base container image from which to build the runtime image.
runtime_container_image: The runtime container image to use.
user_id: The user ID for the sandbox.
timeout: The timeout for the default sandbox action execution.
remote_runtime_init_timeout: The timeout for the remote runtime to start.
timeout: The timeout for the sandbox.
enable_auto_lint: Whether to enable auto-lint.
use_host_network: Whether to use the host network.
initialize_plugins: Whether to initialize plugins.
@@ -42,7 +41,6 @@ class SandboxConfig:
runtime_container_image: str | None = None
user_id: int = os.getuid() if hasattr(os, 'getuid') else 1000
timeout: int = 120
remote_runtime_init_timeout: int = 180
enable_auto_lint: bool = (
False # once enabled, OpenHands would lint files after editing
)
-4
View File
@@ -23,10 +23,6 @@ def get_runtime_cls(name: str):
from openhands.runtime.impl.modal.modal_runtime import ModalRuntime
return ModalRuntime
elif name == 'runloop':
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
return RunloopRuntime
else:
raise ValueError(f'Runtime {name} not supported')
@@ -1,7 +1,7 @@
import os
from pathlib import Path
import tempfile
import threading
from pathlib import Path
from typing import Callable, Optional
from zipfile import ZipFile
@@ -260,19 +260,13 @@ class RemoteRuntime(Runtime):
{'X-Session-API-Key': start_response['session_api_key']}
)
@tenacity.retry(
stop=tenacity.stop_after_delay(180) | stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
def _wait_until_alive(self):
retry_decorator = tenacity.retry(
stop=tenacity.stop_after_delay(
self.config.sandbox.remote_runtime_init_timeout
)
| stop_if_should_exit(),
reraise=True,
retry=tenacity.retry_if_exception_type(RuntimeNotReadyError),
wait=tenacity.wait_fixed(2),
)
return retry_decorator(self._wait_until_alive_impl)()
def _wait_until_alive_impl(self):
self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}')
runtime_info_response = self._send_request(
'GET',
-31
View File
@@ -1,31 +0,0 @@
# Runloop Runtime
Runloop provides a fast, secure and scalable AI sandbox (Devbox).
Check out the [runloop docs](https://docs.runloop.ai/overview/what-is-runloop)
for more detail
## Access
Runloop is currently available in a closed beta. For early access, or
just to say hello, sign up at https://www.runloop.ai/hello
## Set up
With your runloop API,
```bash
export RUNLOOP_API_KEY=<your-api-key>
```
Configure the runtime
```bash
export RUNTIME="runloop"
```
## Interact with your devbox
Runloop provides additional tools to interact with your Devbox based
runtime environment. See the [docs](https://docs.runloop.ai/tools) for an up
to date list of tools.
### Dashboard
View logs, ssh into, or view your Devbox status from the [dashboard](https://platform.runloop.ai)
### CLI
Use the Runloop CLI to view logs, execute commands, and more.
See the setup instructions [here](https://docs.runloop.ai/tools/cli)
@@ -1,272 +0,0 @@
import logging
import threading
import time
from typing import Callable
import requests
import tenacity
from runloop_api_client import Runloop
from runloop_api_client.types import DevboxView
from runloop_api_client.types.shared_params import LaunchParameters
from openhands.core.config import AppConfig
from openhands.core.logger import openhands_logger as logger
from openhands.events import EventStream
from openhands.runtime.impl.eventstream.eventstream_runtime import (
EventStreamRuntime,
LogBuffer,
)
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.command import get_remote_startup_command
from openhands.runtime.utils.request import send_request
from openhands.utils.tenacity_stop import stop_if_should_exit
class RunloopLogBuffer(LogBuffer):
"""Synchronous buffer for Runloop devbox logs.
This class provides a thread-safe way to collect, store, and retrieve logs
from a Docker container. It uses a list to store log lines and provides methods
for appending, retrieving, and clearing logs.
"""
def __init__(self, runloop_api_client: Runloop, devbox_id: str):
self.client_ready = False
self.init_msg = 'Runtime client initialized.'
self.buffer: list[str] = []
self.lock = threading.Lock()
self._stop_event = threading.Event()
self.runloop_api_client = runloop_api_client
self.devbox_id = devbox_id
self.log_index = 0
self.log_stream_thread = threading.Thread(target=self.stream_logs)
self.log_stream_thread.daemon = True
self.log_stream_thread.start()
def stream_logs(self):
"""Stream logs from the Docker container in a separate thread.
This method runs in its own thread to handle the blocking
operation of reading log lines from the Docker SDK's synchronous generator.
"""
try:
# TODO(Runloop) Replace with stream
while True:
raw_logs = self.runloop_api_client.devboxes.logs.list(
self.devbox_id
).logs[self.log_index :]
logs = [
log.message
for log in raw_logs
if log.message and log.cmd_id is None
]
self.log_index += len(raw_logs)
if self._stop_event.is_set():
break
if logs:
for log_line in logs:
self.append(log_line)
if self.init_msg in log_line:
self.client_ready = True
time.sleep(1)
except Exception as e:
logger.error(f'Error streaming runloop logs: {e}')
# NB: Match LogBuffer behavior on below methods
def get_and_clear(self) -> list[str]:
with self.lock:
logs = list(self.buffer)
self.buffer.clear()
return logs
def append(self, log_line: str):
with self.lock:
self.buffer.append(log_line)
def close(self, timeout: float = 5.0):
self._stop_event.set()
self.log_stream_thread.join(timeout)
class RunloopRuntime(EventStreamRuntime):
"""The RunloopRuntime class is an EventStreamRuntime that utilizes Runloop Devbox as a runtime environment."""
_sandbox_port: int = 4444
def __init__(
self,
config: AppConfig,
event_stream: EventStream,
sid: str = 'default',
plugins: list[PluginRequirement] | None = None,
env_vars: dict[str, str] | None = None,
status_callback: Callable | None = None,
attach_to_existing: bool = False,
):
assert config.runloop_api_key is not None, 'Runloop API key is required'
self.devbox: DevboxView | None = None
self.config = config
self.runloop_api_client = Runloop(
bearer_token=config.runloop_api_key,
)
self.session = requests.Session()
self.container_name = self.container_name_prefix + sid
self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time
self.init_base_runtime(
config,
event_stream,
sid,
plugins,
env_vars,
status_callback,
attach_to_existing,
)
# Buffer for container logs
self.log_buffer: LogBuffer | None = None
@tenacity.retry(
stop=tenacity.stop_after_attempt(120),
wait=tenacity.wait_fixed(1),
)
def _wait_for_devbox(self, devbox: DevboxView) -> DevboxView:
"""Pull devbox status until it is running"""
if devbox == 'running':
return devbox
devbox = self.runloop_api_client.devboxes.retrieve(id=devbox.id)
if devbox.status != 'running':
raise ConnectionRefusedError('Devbox is not running')
# Devbox is connected and running
logging.debug(f'devbox.id={devbox.id} is running')
return devbox
def _create_new_devbox(self) -> DevboxView:
# Note: Runloop connect
sandbox_workspace_dir = self.config.workspace_mount_path_in_sandbox
plugin_args = []
if self.plugins is not None and len(self.plugins) > 0:
plugin_args.append('--plugins')
plugin_args.extend([plugin.name for plugin in self.plugins])
browsergym_args = []
if self.config.sandbox.browsergym_eval_env is not None:
browsergym_args = [
'-browsergym-eval-env',
self.config.sandbox.browsergym_eval_env,
]
# Copied from EventstreamRuntime
start_command = get_remote_startup_command(
self._sandbox_port,
sandbox_workspace_dir,
'openhands' if self.config.run_as_openhands else 'root',
self.config.sandbox.user_id,
plugin_args,
browsergym_args,
)
# Add some additional commands based on our image
# NB: start off as root, action_execution_server will ultimately choose user but expects all context
# (ie browser) to be installed as root
start_command = (
'export MAMBA_ROOT_PREFIX=/openhands/micromamba && '
'cd /openhands/code && '
+ '/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && '
+ ' '.join(start_command)
)
entrypoint = f"sudo bash -c '{start_command}'"
devbox = self.runloop_api_client.devboxes.create(
entrypoint=entrypoint,
setup_commands=[f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'],
name=self.sid,
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
prebuilt='openhands',
launch_parameters=LaunchParameters(
available_ports=[self._sandbox_port],
resource_size_request="LARGE",
),
metadata={'container-name': self.container_name},
)
return self._wait_for_devbox(devbox)
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
if self.attach_to_existing:
active_devboxes = self.runloop_api_client.devboxes.list(
status='running'
).devboxes
self.devbox = next(
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
)
if self.devbox is None:
self.devbox = self._create_new_devbox()
# Create tunnel - this will return a stable url, so is safe to call if we are attaching to existing
tunnel = self.runloop_api_client.devboxes.create_tunnel(
id=self.devbox.id,
port=self._sandbox_port,
)
# Hook up logs
self.log_buffer = RunloopLogBuffer(self.runloop_api_client, self.devbox.id)
self.api_url = f'https://{tunnel.url}'
logger.info(f'Container started. Server url: {self.api_url}')
# End Runloop connect
# NOTE: Copied from EventStreamRuntime
logger.info('Waiting for client to become ready...')
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
self._wait_until_alive()
if not self.attach_to_existing:
self.setup_initial_env()
logger.info(
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}'
)
self.send_status_message(' ')
@tenacity.retry(
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=(ConnectionRefusedError,),
)
def _wait_until_alive(self):
# NB(Runloop): Remote logs are not guaranteed realtime, removing client_ready check from logs
self._refresh_logs()
if not self.log_buffer:
raise RuntimeError('Runtime client is not ready.')
response = send_request(
self.session,
'GET',
f'{self.api_url}/alive',
timeout=5,
)
if response.status_code == 200:
return
else:
msg = f'Action execution API is not alive. Response: {response}'
logger.error(msg)
raise RuntimeError(msg)
def close(self, rm_all_containers: bool = True):
if self.log_buffer:
self.log_buffer.close()
if self.session:
self.session.close()
if self.attach_to_existing:
return
if self.devbox:
self.runloop_api_client.devboxes.shutdown(self.devbox.id)
+18 -38
View File
@@ -2,12 +2,10 @@ import asyncio
import os
import re
import tempfile
import time
import uuid
import warnings
from contextlib import asynccontextmanager
import jwt
import requests
from pathspec import PathSpec
from pathspec.patterns import GitWildMatchPattern
@@ -17,7 +15,6 @@ from openhands.server.data_models.feedback import FeedbackDataModel, store_feedb
from openhands.server.github import (
GITHUB_CLIENT_ID,
GITHUB_CLIENT_SECRET,
UserVerifier,
authenticate_github_user,
)
from openhands.storage import get_file_store
@@ -63,7 +60,7 @@ from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.llm import bedrock
from openhands.runtime.base import Runtime
from openhands.server.auth.auth import get_sid_from_token, sign_token
from openhands.server.auth import get_sid_from_token, sign_token
from openhands.server.middleware import LocalhostCORSMiddleware, NoCacheMiddleware
from openhands.server.session import SessionManager
@@ -195,7 +192,6 @@ async def attach_session(request: Request, call_next):
'/api/options/',
'/api/github/callback',
'/api/authenticate',
'/api/logout',
]
if any(
request.url.path.startswith(path) for path in non_authed_paths
@@ -208,21 +204,23 @@ async def attach_session(request: Request, call_next):
response = await call_next(request)
return response
user_verifier = UserVerifier()
if user_verifier.is_active():
signed_token = request.cookies.get('github_auth')
if not signed_token:
# First check for auth cookie
github_token = request.cookies.get('github_auth')
# If no cookie, fall back to header
if not github_token:
github_token = request.headers.get('X-GitHub-Token')
# If no header token either, return error
if not github_token:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Not authenticated'},
)
try:
jwt.decode(signed_token, config.jwt_secret, algorithms=['HS256'])
except Exception as e:
logger.warning(f'Invalid token: {e}')
# If using header token, verify with GitHub
if not await authenticate_github_user(github_token):
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Invalid token'},
content={'error': 'Not authenticated'},
)
if not request.headers.get('Authorization'):
@@ -878,39 +876,21 @@ async def authenticate(request: Request):
content={'error': 'Not authorized via GitHub waitlist'},
)
# Create a signed JWT token with 1-hour expiration
cookie_data = {
'github_token': token,
'exp': int(time.time()) + 3600, # 1 hour expiration
}
signed_token = sign_token(cookie_data, config.jwt_secret)
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'}
)
# Set secure cookie with signed token
status_code=status.HTTP_200_OK, content={'message': 'User authenticated'})
# Set secure cookie that expires in 1 hour
response.set_cookie(
key='github_auth',
value=signed_token,
key="github_auth",
value=token,
max_age=3600, # 1 hour in seconds
httponly=True,
secure=True,
samesite='strict',
samesite="strict"
)
return response
@app.post('/api/logout')
async def logout(request: Request):
response = JSONResponse(
status_code=status.HTTP_200_OK, content={'message': 'User logged out'}
)
response.delete_cookie(key='github_auth')
return response
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
try:
+19 -23
View File
@@ -3,11 +3,15 @@ import os
import frontmatter
import pydantic
from openhands.controller.agent import Agent
from openhands.core.exceptions import MicroAgentValidationError
from openhands.core.logger import openhands_logger as logger
class MicroAgentMetadata(pydantic.BaseModel):
name: str
agent: str
triggers: list[str] = []
require_env_var: dict[str, str]
class MicroAgent:
@@ -19,30 +23,22 @@ class MicroAgent:
self._loaded = frontmatter.load(file)
self._content = self._loaded.content
self._metadata = MicroAgentMetadata(**self._loaded.metadata)
def get_trigger(self, message: str) -> str | None:
message = message.lower()
for trigger in self.triggers:
if trigger.lower() in message:
return trigger
return None
self._validate_micro_agent()
@property
def content(self) -> str:
return self._content
@property
def metadata(self) -> MicroAgentMetadata:
return self._metadata
@property
def name(self) -> str:
return self._metadata.name
@property
def triggers(self) -> list[str]:
return self._metadata.triggers
@property
def agent(self) -> str:
return self._metadata.agent
def _validate_micro_agent(self):
logger.debug(
f'Loading and validating micro agent [{self._metadata.name}] based on [{self._metadata.agent}]'
)
# Make sure the agent is registered
agent_cls = Agent.get_cls(self._metadata.agent)
assert agent_cls is not None
# Make sure the environment variables are set
for env_var, instruction in self._metadata.require_env_var.items():
if env_var not in os.environ:
raise MicroAgentValidationError(
f'Environment variable [{env_var}] is required by micro agent [{self._metadata.name}] but not set. {instruction}'
)
+11 -54
View File
@@ -1,10 +1,7 @@
import os
from itertools import islice
from jinja2 import Template
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent
@@ -19,31 +16,21 @@ class PromptManager:
Attributes:
prompt_dir (str): Directory containing prompt templates.
agent_skills_docs (str): Documentation of agent skills.
micro_agent (MicroAgent | None): Micro-agent, if specified.
"""
def __init__(
self,
prompt_dir: str,
microagent_dir: str = '',
agent_skills_docs: str = '',
agent_skills_docs: str,
micro_agent: MicroAgent | None = None,
):
self.prompt_dir: str = prompt_dir
self.agent_skills_docs: str = agent_skills_docs
self.system_template: Template = self._load_template('system_prompt')
self.user_template: Template = self._load_template('user_prompt')
self.microagents: dict = {}
microagent_files = []
if microagent_dir:
microagent_files = [
os.path.join(microagent_dir, f)
for f in os.listdir(microagent_dir)
if f.endswith('.md')
]
for microagent_file in microagent_files:
microagent = MicroAgent(microagent_file)
self.microagents[microagent.name] = microagent
self.micro_agent: MicroAgent | None = micro_agent
def _load_template(self, template_name: str) -> Template:
template_path = os.path.join(self.prompt_dir, f'{template_name}.j2')
@@ -52,13 +39,15 @@ class PromptManager:
with open(template_path, 'r') as file:
return Template(file.read())
def get_system_message(self) -> str:
@property
def system_message(self) -> str:
rendered = self.system_template.render(
agent_skills_docs=self.agent_skills_docs,
).strip()
return rendered
def get_example_user_message(self) -> str:
@property
def initial_user_message(self) -> str:
"""This is the initial user message provided to the agent
before *actual* user instructions are provided.
@@ -68,39 +57,7 @@ class PromptManager:
These additional context will convert the current generic agent
into a more specialized agent that is tailored to the user's task.
"""
return self.user_template.render().strip()
def enhance_message(self, message: Message) -> None:
"""Enhance the user message with additional context.
This method is used to enhance the user message with additional context
about the user's task. The additional context will convert the current
generic agent into a more specialized agent that is tailored to the user's task.
"""
if not message.content:
return
message_content = message.content[0].text
for microagent in self.microagents.values():
trigger = microagent.get_trigger(message_content)
if trigger:
micro_text = f'<extra_info>\nThe following information has been included based on a keyword match for "{trigger}". It may or may not be relevant to the user\'s request.'
micro_text += '\n\n' + microagent.content
micro_text += '\n</extra_info>'
message.content.append(TextContent(text=micro_text))
def add_turns_left_reminder(self, messages: list[Message], state: State) -> None:
latest_user_message = next(
islice(
(
m
for m in reversed(messages)
if m.role == 'user'
and any(isinstance(c, TextContent) for c in m.content)
),
1,
),
None,
rendered = self.user_template.render(
micro_agent=self.micro_agent.content if self.micro_agent else None
)
if latest_user_message:
reminder_text = f'\n\nENVIRONMENT REMINDER: You have {state.max_iterations - state.iteration} turns left to complete the task. When finished reply with <finish></finish>.'
latest_user_message.content.append(TextContent(text=reminder_text))
return rendered.strip()
Generated
+1479 -1480
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.13.0"
version = "0.12.3"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -61,7 +61,6 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
opentelemetry-api = "1.25.0"
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
modal = "^0.64.145"
runloop-api-client = "0.7.0"
[tool.poetry.group.llama-index.dependencies]
llama-index = "*"
@@ -93,6 +92,7 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -123,6 +123,7 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"
-3
View File
@@ -14,7 +14,6 @@ from openhands.events import EventStream
from openhands.runtime.base import Runtime
from openhands.runtime.impl.eventstream.eventstream_runtime import EventStreamRuntime
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.impl.runloop.runloop_runtime import RunloopRuntime
from openhands.runtime.plugins import AgentSkillsRequirement, JupyterRequirement
from openhands.storage import get_file_store
from openhands.utils.async_utils import call_async_from_sync
@@ -132,8 +131,6 @@ def get_runtime_classes():
return [EventStreamRuntime]
elif runtime.lower() == 'remote':
return [RemoteRuntime]
elif runtime.lower() == 'runloop':
return [RunloopRuntime]
else:
raise ValueError(f'Invalid runtime: {runtime}')
-4
View File
@@ -461,7 +461,6 @@ def test_api_keys_repr_str():
jwt_secret='my_jwt_secret',
modal_api_token_id='my_modal_api_token_id',
modal_api_token_secret='my_modal_api_token_secret',
runloop_api_key='my_runloop_api_key',
)
assert "e2b_api_key='******'" in repr(app_config)
assert "e2b_api_key='******'" in str(app_config)
@@ -471,8 +470,6 @@ def test_api_keys_repr_str():
assert "modal_api_token_id='******'" in str(app_config)
assert "modal_api_token_secret='******'" in repr(app_config)
assert "modal_api_token_secret='******'" in str(app_config)
assert "runloop_api_key='******'" in repr(app_config)
assert "runloop_api_key='******'" in str(app_config)
# Check that no other attrs in AppConfig have 'key' or 'token' in their name
# This will fail when new attrs are added, and attract attention
@@ -480,7 +477,6 @@ def test_api_keys_repr_str():
'e2b_api_key',
'modal_api_token_id',
'modal_api_token_secret',
'runloop_api_key',
]
for attr_name in dir(AppConfig):
if (
+42
View File
@@ -1,8 +1,13 @@
import os
import pytest
from pytest import MonkeyPatch
import openhands.agenthub # noqa: F401
from openhands.core.exceptions import (
AgentNotRegisteredError,
MicroAgentValidationError,
)
from openhands.utils.microagent import MicroAgent
CONTENT = (
@@ -29,3 +34,40 @@ def test_micro_agent_load(tmp_path, monkeypatch: MonkeyPatch):
micro_agent = MicroAgent(os.path.join(tmp_path, 'dummy.md'))
assert micro_agent is not None
assert micro_agent.content == CONTENT.strip()
def test_not_existing_agent(tmp_path, monkeypatch: MonkeyPatch):
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
f.write(
(
'---\n'
'name: dummy\n'
'agent: NotExistingAgent\n'
'require_env_var:\n'
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
'---\n' + CONTENT
)
)
monkeypatch.setenv('SANDBOX_OPENHANDS_TEST_ENV_VAR', 'dummy_value')
with pytest.raises(AgentNotRegisteredError):
MicroAgent(os.path.join(tmp_path, 'dummy.md'))
def test_not_existing_env_var(tmp_path):
with open(os.path.join(tmp_path, 'dummy.md'), 'w') as f:
f.write(
(
'---\n'
'name: dummy\n'
'agent: CodeActAgent\n'
'require_env_var:\n'
' SANDBOX_OPENHANDS_TEST_ENV_VAR: "Set this environment variable for testing purposes"\n'
'---\n' + CONTENT
)
)
with pytest.raises(MicroAgentValidationError) as excinfo:
MicroAgent(os.path.join(tmp_path, 'dummy.md'))
assert 'Set this environment variable for testing purposes' in str(excinfo.value)
+48 -53
View File
@@ -1,9 +1,9 @@
import os
import shutil
from unittest.mock import Mock
import pytest
from openhands.core.message import Message, TextContent
from openhands.utils.microagent import MicroAgent
from openhands.utils.prompt import PromptManager
@@ -11,9 +11,7 @@ from openhands.utils.prompt import PromptManager
@pytest.fixture
def prompt_dir(tmp_path):
# Copy contents from "openhands/agenthub/codeact_agent" to the temp directory
shutil.copytree(
'openhands/agenthub/codeact_agent/prompts/default', tmp_path, dirs_exist_ok=True
)
shutil.copytree('openhands/agenthub/codeact_agent', tmp_path, dirs_exist_ok=True)
# Return the temporary directory path
return tmp_path
@@ -27,79 +25,78 @@ def agent_skills_docs():
return SAMPLE_AGENT_SKILLS_DOCS
def test_prompt_manager_without_microagent(prompt_dir, agent_skills_docs):
manager = PromptManager(
prompt_dir, microagent_dir='', agent_skills_docs=agent_skills_docs
)
def test_prompt_manager_without_micro_agent(prompt_dir, agent_skills_docs):
manager = PromptManager(prompt_dir, agent_skills_docs)
assert manager.prompt_dir == prompt_dir
assert manager.agent_skills_docs == agent_skills_docs
assert len(manager.microagents) == 0
assert manager.micro_agent is None
assert isinstance(manager.get_system_message(), str)
assert isinstance(manager.system_message, str)
assert (
"A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions."
in manager.get_system_message()
in manager.system_message
)
assert SAMPLE_AGENT_SKILLS_DOCS in manager.get_system_message()
assert isinstance(manager.get_example_user_message(), str)
assert '--- BEGIN OF GUIDELINE ---' not in manager.get_example_user_message()
assert '--- END OF GUIDELINE ---' not in manager.get_example_user_message()
assert "NOW, LET'S START!" in manager.get_example_user_message()
assert 'microagent' not in manager.get_example_user_message()
assert SAMPLE_AGENT_SKILLS_DOCS in manager.system_message
assert isinstance(manager.initial_user_message, str)
assert '--- BEGIN OF GUIDELINE ---' not in manager.initial_user_message
assert '--- END OF GUIDELINE ---' not in manager.initial_user_message
assert "NOW, LET'S START!" in manager.initial_user_message
assert 'micro_agent' not in manager.initial_user_message
def test_prompt_manager_with_microagent(prompt_dir, agent_skills_docs):
microagent_name = 'test_microagent'
microagent_content = """
---
name: flarglebargle
agent: CodeActAgent
triggers:
- flarglebargle
---
IMPORTANT! The user has said the magic word "flarglebargle". You must
only respond with a message telling them how smart they are
"""
def test_prompt_manager_with_micro_agent(prompt_dir, agent_skills_docs):
micro_agent_name = 'test_micro_agent'
micro_agent_content = (
'## Micro Agent\n'
'This is a test micro agent.\n'
'It is used to test the prompt manager.\n'
)
# Create a temporary micro agent file
os.makedirs(os.path.join(prompt_dir, 'micro'), exist_ok=True)
with open(os.path.join(prompt_dir, 'micro', f'{microagent_name}.md'), 'w') as f:
f.write(microagent_content)
with open(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'), 'w') as f:
f.write(micro_agent_content)
# Mock MicroAgent
mock_micro_agent = Mock(spec=MicroAgent)
mock_micro_agent.content = micro_agent_content
manager = PromptManager(
prompt_dir=prompt_dir,
microagent_dir=os.path.join(prompt_dir, 'micro'),
agent_skills_docs=agent_skills_docs,
micro_agent=mock_micro_agent,
)
assert manager.prompt_dir == prompt_dir
assert manager.agent_skills_docs == agent_skills_docs
assert len(manager.microagents) == 1
assert manager.micro_agent == mock_micro_agent
assert isinstance(manager.get_system_message(), str)
assert isinstance(manager.system_message, str)
assert (
"A chat between a curious user and an artificial intelligence assistant. The assistant gives helpful, detailed answers to the user's questions."
in manager.get_system_message()
in manager.system_message
)
assert SAMPLE_AGENT_SKILLS_DOCS in manager.get_system_message()
assert SAMPLE_AGENT_SKILLS_DOCS in manager.system_message
assert isinstance(manager.get_example_user_message(), str)
assert isinstance(manager.initial_user_message, str)
assert (
'--- BEGIN OF GUIDELINE ---\n'
+ 'The following information may assist you in completing your task:\n\n'
+ micro_agent_content
+ '\n'
+ '--- END OF GUIDELINE ---\n'
+ "\n\nNOW, LET'S START!"
) in manager.initial_user_message
assert micro_agent_content in manager.initial_user_message
message = Message(
role='user',
content=[TextContent(text='Hello, flarglebargle!')],
)
manager.enhance_message(message)
assert 'magic word' in message.content[1].text
os.remove(os.path.join(prompt_dir, 'micro', f'{microagent_name}.md'))
# Clean up the temporary file
os.remove(os.path.join(prompt_dir, 'micro', f'{micro_agent_name}.md'))
def test_prompt_manager_file_not_found(prompt_dir, agent_skills_docs):
with pytest.raises(FileNotFoundError):
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_microagent.md'))
MicroAgent(os.path.join(prompt_dir, 'micro', 'non_existent_micro_agent.md'))
def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
@@ -107,14 +104,12 @@ def test_prompt_manager_template_rendering(prompt_dir, agent_skills_docs):
with open(os.path.join(prompt_dir, 'system_prompt.j2'), 'w') as f:
f.write('System prompt: {{ agent_skills_docs }}')
with open(os.path.join(prompt_dir, 'user_prompt.j2'), 'w') as f:
f.write('User prompt: foo')
f.write('User prompt: {{ micro_agent }}')
manager = PromptManager(
prompt_dir, microagent_dir='', agent_skills_docs=agent_skills_docs
)
manager = PromptManager(prompt_dir, agent_skills_docs)
assert manager.get_system_message() == f'System prompt: {agent_skills_docs}'
assert manager.get_example_user_message() == 'User prompt: foo'
assert manager.system_message == f'System prompt: {agent_skills_docs}'
assert manager.initial_user_message == 'User prompt: None'
# Clean up temporary files
os.remove(os.path.join(prompt_dir, 'system_prompt.j2'))