Skip to content

feat(agent-server): load hooks.json for v1 hooks#2035

Open
ak684 wants to merge 6 commits intomainfrom
support-openhands-hooks-json-in-agent-server
Open

feat(agent-server): load hooks.json for v1 hooks#2035
ak684 wants to merge 6 commits intomainfrom
support-openhands-hooks-json-in-agent-server

Conversation

@ak684
Copy link
Contributor

@ak684 ak684 commented Feb 13, 2026

Summary

Enable V1 agent‑server to auto‑load .openhands/hooks.json from the workspace and merge it with any request‑provided hooks (request hooks first), so repo‑level hooks run in SaaS just like CLI.

Checklist

  • If the PR is changing/adding functionality, are there tests to reflect this?
  • If there is an example, have you run the example to make sure that it works?
  • If there are instructions on how to run the code, have you followed the instructions and made sure that it works?
  • If the feature is significant enough to require documentation, is there a PR open on the OpenHands/docs repository with the same branch name?
  • Is the github CI passing?

Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.12-nodejs22 Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:ee040f3-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-ee040f3-python \
  ghcr.io/openhands/agent-server:ee040f3-python

All tags pushed for this build

ghcr.io/openhands/agent-server:ee040f3-golang-amd64
ghcr.io/openhands/agent-server:ee040f3-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:ee040f3-golang-arm64
ghcr.io/openhands/agent-server:ee040f3-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:ee040f3-java-amd64
ghcr.io/openhands/agent-server:ee040f3-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:ee040f3-java-arm64
ghcr.io/openhands/agent-server:ee040f3-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:ee040f3-python-amd64
ghcr.io/openhands/agent-server:ee040f3-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-amd64
ghcr.io/openhands/agent-server:ee040f3-python-arm64
ghcr.io/openhands/agent-server:ee040f3-nikolaik_s_python-nodejs_tag_python3.12-nodejs22-arm64
ghcr.io/openhands/agent-server:ee040f3-golang
ghcr.io/openhands/agent-server:ee040f3-java
ghcr.io/openhands/agent-server:ee040f3-python

About Multi-Architecture Support

  • Each variant tag (e.g., ee040f3-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., ee040f3-python-amd64) are also available if needed

Co-authored-by: openhands <openhands@all-hands.dev>
Copy link
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating: 🟡 Acceptable (but needs critical fix)

Verdict: ❌ Needs rework - Missing error handling could crash production

Key Insight: The lack of error handling around file loading violates "never break userspace" - a typo in hooks.json will crash the service instead of falling back gracefully.


The core logic is sound and solves a real problem (repo-level hooks in SaaS). However, there is a critical error handling gap and the code is more complex than it needs to be. See inline comments for details.

@github-actions
Copy link
Contributor

github-actions bot commented Feb 13, 2026

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   event_service.py3328574%62–63, 83, 88–93, 96–99, 114, 218, 237, 294–295, 299, 307, 310, 356–357, 373, 375, 379–381, 385, 394–395, 397, 401, 407, 409, 417–422, 570, 572–573, 577, 591–593, 595, 599–602, 606–609, 617–620, 639–640, 642–649, 651–652, 661–662, 664–665, 672–673, 675–676, 680, 686, 703–704
openhands-sdk/openhands/sdk/hooks
   config.py1491291%73, 85–86, 92, 94, 171, 176, 237, 286–287, 289–290
TOTAL18640494473% 

@enyst enyst requested a review from all-hands-bot February 13, 2026 18:23
Copy link
Collaborator

@all-hands-bot all-hands-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taste Rating: 🟡 Acceptable - Logic is sound but has some verbosity issues

The core idea is solid: load repo hooks from .openhands/hooks.json and merge with request hooks. However, the implementation has repeated patterns that violate DRY and some overly broad error handling. See inline comments for details.

…ce duplication

Address PR feedback about DRY violation - the "check if not None and is_empty(),
then set to None" pattern was repeated twice.
@enyst enyst added behavior-initiative This is related to the system prompt sections and LLM steering. and removed behavior-initiative This is related to the system prompt sections and LLM steering. labels Feb 14, 2026
Copy link
Collaborator

@xingyaoww xingyaoww left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think maybe we should've implement this from the App side, rather than keep these in the SDK?
OpenHands/OpenHands#12773

@ak684
Copy link
Contributor Author

ak684 commented Feb 15, 2026

I think maybe we should've implement this from the App side, rather than keep these in the SDK? OpenHands/OpenHands#12773

I 100% disagree. It's simpler to implement/resolve reading project hooks.json hooks in the SDK first and then the app (and ALL other consumers of the SDK) can read it automatically, not the other way around.

I also need this PR approved for a client POC this week. So that's another motivation for the simpler, working approach proposed here.

…hook sources

Addresses PR feedback to support both project-level and user-level hooks,
similar to how skills work with load_project_skills and load_user_skills.

Changes:
- Add load_project_hooks(working_dir) to load from {working_dir}/.openhands/hooks.json
- Add load_user_hooks() to load from ~/.openhands/hooks.json
- Update event_service.py to merge hooks from all sources (request > project > user)
- Add comprehensive tests for new functions
@openhands-ai
Copy link

openhands-ai bot commented Feb 15, 2026

Looks like there are a few issues preventing this PR from being merged!

  • GitHub Actions are failing:
    • Review Thread Gate

If you'd like me to help, just leave a comment, like

@OpenHands please fix the failing actions on PR #2035 at branch `support-openhands-hooks-json-in-agent-server`

Feel free to include any additional details that might help me get this PR into a better state.

You can manage your notification settings


project_hook_config = load_project_hooks(workspace.working_dir)
user_hook_config = load_user_hooks()

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this load_project_hooks and load_user_hooks could be disabled by default?

Please correct me if I’m wrong, I think load_project_skills/user is disabled by default in the SDK, we have the mechanism here, but we don’t load all the things from user machine - the decision True comes from the caller application 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@OpenHands Clone yourself the CLI and the repo named OpenHands, from the same org, and investigate what I just said: who loads Skills (project, user) in the CLI, and on the V1 app_server?

Note that OpenHands repo is a bit chaotic, it has V0 legacy code. We only want to follow the execution path in openhands/app_server, V1, and find where we call the SDK for skills / with skills and what does app-server do.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on it! enyst can track my progress at all-hands.dev

Copy link
Collaborator

@enyst enyst Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test reply

Edited to add: HUMAN: hah! I sneaked in the conversation and told the agent to be careful to REPLY, because these days idk why, it sometimes wiped previous comment, “replying” by editing the question 😅
… I mean, I guess it was convenient 😂

Copy link
Collaborator

@enyst enyst Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TL;DR

  • SDK defaults are safe: AgentContext(load_user_skills=False, load_public_skills=False) by default.
  • CLI explicitly opts in to loading user + public skills (and always loads project skills).
  • V1 app-server explicitly opts in to loading public + user + org + project + sandbox skills by delegating to agent-server (POST /skills).
  • In V1, the app-server does not load skills from disk itself; it’s a thin orchestrator/proxy. The agent-server is the component that touches the filesystem (workspace) and home dir.

1) SDK invariants / defaults

In the SDK, skills loading is off by default.

AgentContext defaults

In software-agent-sdk/openhands-sdk/openhands/sdk/context/agent_context.py:

  • load_user_skills: bool = False
  • load_public_skills: bool = False

When load_user_skills=True, AgentContext runs a model validator that calls load_user_skills() and merges them into skills (dedup by name).

Project skills are not auto-loaded by AgentContext

load_project_skills(work_dir) is a separate helper in sdk/context/skills/skill.py.

So project-skill loading is always a caller decision.


2) Who loads skills in the CLI?

In the CLI repo (OpenHands-CLI), the loading decision is in:

openhands_cli/stores/agent_store.py

Function _build_agent_context():

skills = load_project_skills(get_work_dir())
...
return AgentContext(
    skills=skills,
    system_message_suffix=system_suffix,
    load_user_skills=True,
    load_public_skills=True,
)

This matches the idea that the SDK has the mechanism, but the application chooses to turn it on.


3) Who loads skills in V1 app-server (openhands/app_server)?

V1 app-server does not call sdk.context.load_project_skills() or sdk.context.load_user_skills() directly.
Instead, it delegates to the agent-server.

Entry point

In OpenHands/openhands/app_server/app_conversation/live_status_app_conversation_service.py, conversation creation calls:

  • _finalize_conversation_request(...)
  • which, if remote_workspace exists, calls _load_skills_and_update_agent(...)

load_and_merge_all_skills() in V1

In OpenHands/openhands/app_server/app_conversation/app_conversation_service_base.py:

all_skills = await load_skills_from_agent_server(
    agent_server_url=agent_server_url,
    session_api_key=sandbox.session_api_key,
    project_dir=project_dir,
    org_config=org_config,
    sandbox_config=sandbox_config,
    load_public=True,
    load_user=True,
    load_project=True,
    load_org=True,
)

So V1 app-server explicitly opts in to everything (public/user/project/org + sandbox skill).

What app-server does vs agent-server does

App-server responsibilities:

  • figures out org-level repo path ({org}/.openhands vs openhands-config etc.) and obtains a pre-authenticated git URL (so agent-server can clone without interactive auth)
  • passes sandbox exposed URLs
  • computes project_dir inside the workspace (e.g. working_dir/<repo_name>)
  • calls agent-server endpoint

Agent-server responsibilities:

  • actually loads skills from sources:
    • public: load_public_skills()
    • user: load_user_skills() (home dir inside the agent-server runtime)
    • project: load_project_skills(project_dir) (workspace filesystem)
    • org: clones org repo URL and loads from it
    • sandbox: generates a synthetic work_hosts skill

This is implemented in software-agent-sdk/openhands-agent-server/openhands/agent_server/skills_service.py::load_all_skills() and exposed via skills_router (POST /skills).


4) Important implication (re: “don’t load things from user machine by default”)

  • SDK keeps the invariant: user/public skills are disabled by default.
  • CLI enables them because it’s running on the user’s machine and that’s expected.
  • V1 app-server enables them because it wants SaaS parity with CLI, but note that load_user_skills() on agent-server will read from the agent-server container’s $HOME/.openhands/....
    • In SaaS, that home directory is not the end-user’s machine, but the agent runtime’s home.
    • So “user skills” in SaaS effectively means “skills baked/volume-mounted into the agent runtime”, unless you explicitly sync user skills into that home.

So, if the product/security policy is “don’t implicitly load user/home content in hosted mode”, the knob for that in V1 is the booleans passed from app-server to agent-server (load_user, load_public, etc.). Right now they’re hardcoded to True in V1.


5) Hooks (since it motivated this thread)

Analogous pattern exists for hooks, but currently (in this PR) it’s in agent-server:

  • SDK provides load_project_hooks(working_dir) and load_user_hooks() (both return None if file missing)
  • In openhands-agent-server/openhands/agent_server/event_service.py we currently do:
    request_hook_config = _normalize_hook_config(self.stored.hook_config)
    project_hook_config = load_project_hooks(workspace.working_dir)
    user_hook_config = load_user_hooks()
    merged = HookConfig.merge([request, project, user])

So hooks follow the same “caller decides to enable” pattern — except here the agent-server is unconditionally loading project/user hooks today.

If we want hooks to match skills’ “disabled-by-default unless caller opts-in” invariant, the likely design is to add request flags (e.g. load_project_hooks, load_user_hooks) and plumb them from V1 app-server / CLI similarly to how skills are plumbed.

This comment was marked as duplicate.

This comment was marked as resolved.

Copy link
Collaborator

@enyst enyst Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m OpenHands-GPT-5.2 (an OpenHands dev assistant agent). Here’s a concrete alternative design that mirrors the existing V1 /skills pattern.

Yes — that’s a very coherent design, and it’s actually the closest analogue to how V1 does skills (app-server orchestrates, agent-server loads from workspace). It also avoids adding “filesystem side effects” into event_service.start().

If we spell it out, it’s essentially “move hook source selection out of conversation start” and into an explicit, fetch-style endpoint.


Option F — Add /hooks endpoint (skills-style), app-server orchestrates + merges

Proposed flow

  1. App-server decides policy:
    • should we load project hooks? user hooks? org hooks? plugin hooks? (plugin hooks still handled later in LocalConversation)
  2. App-server calls agent-server:
    • POST /hooks with booleans, plus project_dir / whatever is needed to locate repo root in workspace
  3. Agent-server returns a serialized HookConfig (or null).
  4. App-server merges:
    • request hooks (if any, from UI/API)
    • hooks from /hooks
    • (optionally) app-server-specific hooks
  5. App-server sends StartConversationRequest(hook_config=merged) to agent-server.
  6. Agent-server event_service.start() does no implicit loading, it just uses self.stored.hook_config + later plugin hooks merge inside LocalConversation.

Why this matches existing V1 “skills” architecture

  • App-server does not have the workspace filesystem, agent-server does.
  • Therefore, any “load from workspace” action in V1 generally becomes:
    • app-server calls agent-server endpoint that touches the workspace FS.
  • This is exactly what happens for skills today (POST /skills).

So /hooks fits the pattern and keeps event_service.start() deterministic.


Key design choices to make it clean

1) API shape of /hooks

I’d mirror /skills:

Request

{
  "load_project": true,
  "load_user": false,
  "project_dir": "/path/in/workspace",
  "org_config": null,        // optional, if you ever want org hooks
  "sandbox_config": null     // probably irrelevant for hooks
}

Response

{
  "hook_config": { ... },    // HookConfig JSON
  "sources": { "project": 1, "user": 0 }
}

Even if you start with just project_dir + booleans, leaving room for sources is useful for debugging.

2) Where merging happens

If app-server merges, you need a stable precedence rule. For consistency with this PR discussion, likely:

  • request explicit hooks (highest)
  • project hooks
  • user hooks (lowest)

But note: with hooks, order can matter (since they’re executed sequentially). Your current merge semantics are concatenation-like; precedence mostly matters for “override” cases. So you’ll want to make sure HookConfig.merge() semantics are what you want (I believe it concatenates lists per event, which means “precedence” is effectively “execution order”).

So define it explicitly:

  • “explicit hooks run first” vs “repo hooks run first” etc.
  • For safety, I often prefer: request hooks first, then repo hooks, then user hooks (because request hooks are “session policy”). But you might prefer repo hooks first (repo policy) and user last. Either is defensible; just make it intentional.

3) What about user hooks in SaaS?

Same issue as skills:

  • $HOME/.openhands/hooks.json in SaaS is runtime home, not the end-user machine.
    So app-server might choose:
  • load_user=false in hosted mode
  • load_project=true always

This becomes easy with the endpoint design.


Pros / Cons vs Option B (flags in start request)

Pros

  • Keeps StartConversationRequest schema stable-ish (no new flags required there).
  • Makes hook loading explicit and traceable (you can log /hooks call separately).
  • Keeps event_service.start() deterministic: no extra implicit filesystem reads on start.
  • Mirrors skills design, reduces conceptual inconsistency.

Cons

  • Extra network round-trip during conversation start.
  • More moving parts: new router/service in agent-server + app-server call site.
  • You still need to decide what CLI does (but CLI could just load hooks locally, no need for an endpoint there).

One subtlety: why not have agent-server merge in /hooks endpoint and return merged result?

You can, but if app-server is already the orchestrator, letting it own the merge is nice because it can apply product-specific policy (“hosted mode: no user hooks”). The agent-server can stay a pure “loader”.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(HUMAN) TBH I kinda incline to this Option F, or at least it seems worth considering.

IIRC, some initial implementation of skills was on app-server (trying to load directly from workspace or something), and we didn’t like it and it didn’t work for some sources (it was duplicating loading that was done in the SDK, at a minimum).

So we ended up linking it up here, so that

  • SDK does the loading, exposing it to any type of client, python, cli etc
  • agent-server exposes /skills endpoint
  • app-server decides policy (from this source, not from that etc), when it calls it.

It is a bit more complicated, yes. 🤔 But idk. Is the hooks case a lot different than skills? Does it make sense to you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants