Files
omnigent-ai--omnigent/designs/harness-plugin-interface.md
2026-07-13 13:12:00 +08:00

223 lines
7.9 KiB
Markdown

# Harness Plugin Interface
Omnigent now discovers optional harness support through Python entry points.
Core `omnigent` ships the built-in harness contribution. A separate package, for
example `omnigent-kimi`, can add harness ids, aliases, runner modules, install
metadata, model environment plumbing, and picker labels without adding that
harness to the default install.
The goal is:
- `pip install omnigent` gives only core harnesses.
- `pip install omnigent-kimi` adds Kimi support to the same `omni` CLI and
server process.
- Core can still produce a targeted error for known optional harness ids:
install `omnigent-kimi`.
## Package Contract
An optional harness package declares an entry point in the
`omnigent.community.harness` group. Community harness implementation modules
must also live under the `omnigent.community.harness.*` namespace; core rejects
plugins that try to register flat packages or override builtin harness names.
```toml
[project]
name = "omnigent-foo"
dependencies = [
"omnigent==0.3.0.dev0",
]
[project.entry-points."omnigent.community.harness"]
foo = "omnigent.community.harness.foo.plugin:get_contribution"
```
For local sibling checkouts, keep the package dependency normal and point uv at
the local core checkout:
```toml
[tool.uv.sources]
omnigent = { path = "../omnigent-oss-2", editable = true }
```
If the plugin lives inside the core repo, the relative path should point back to
the repo root. If it moves to a sibling repo, update the path. A bad path is why
uv may try to build `omnigent @ file:///Users/<user>`.
## Registry Types
The public interface lives in `omnigent.harness_plugins`:
```python
from omnigent.harness_plugins import HarnessContribution
from omnigent.harness_install_spec import HarnessInstallSpec
```
`HarnessInstallSpec` intentionally lives outside `omnigent.onboarding` so a
plugin can be imported during entry-point discovery without pulling in the
provider/onboarding stack and creating import cycles.
### `HarnessContribution`
Each plugin exports a `get_contribution()` function returning
`HarnessContribution`.
```python
def get_contribution() -> HarnessContribution:
return HarnessContribution(
name="omnigent-foo",
valid_harnesses=frozenset({"foo"}),
harness_modules={
"foo": "omnigent.community.harness.foo.inner.foo_harness",
},
aliases={
"foo-code": "foo",
},
install_specs={
"foo": HarnessInstallSpec(
"Foo",
"foo",
package=None,
install_hint="curl -fsSL https://foo.example/install.sh | bash",
login_args=("login",),
logout_args=("logout",),
),
},
harness_install_keys={
"foo": "foo",
"foo-code": "foo",
},
missing_install_package={
"foo": "omnigent-foo",
"foo-code": "omnigent-foo",
},
harness_labels={"foo": "Foo"},
)
```
## Field Semantics
`valid_harnesses`
: Canonical harness ids accepted by spec validation once the plugin is
installed.
`harness_modules`
: Maps each canonical harness id to the subprocess module that creates the
harness app. `omnigent.runtime.harnesses` merges these into `_HARNESS_MODULES`.
`aliases`
: User-facing spellings canonicalized by `omnigent.harness_aliases`, for example
`foo-code -> foo`.
`install_specs`
: Plugin-provided CLI install/auth metadata, keyed by install key. Use
`HarnessInstallSpec` from `omnigent.harness_install_spec`.
`harness_install_keys`
: Maps harness ids and aliases to an `install_specs` key. Readiness and
preflight checks use this to decide which CLI binary a harness requires.
`model_env_keys`
: Maps harness id to an env var name used by launcher/spec generation for model
override plumbing.
`spawn_env_builders`
: Maps headless harness id to a callable import path. The runner calls this to
build per-spawn environment variables from the agent spec.
`missing_install_package`
: Maps known optional harness spellings to the package that provides them. Core
uses this even when the plugin is not installed so validation and process-manager
errors can say `pip install omnigent-foo`.
`harness_labels`
: Maps canonical harness ids to display labels returned by `GET /v1/harnesses`
and merged into web picker surfaces.
## Runtime Flow
1. Python loads installed entry points in `omnigent.community.harness`.
2. `omnigent.harness_plugins.plugin_state()` merges the built-in contribution
with each plugin contribution.
3. Spec validation checks `accepted_harnesses()` and uses
`missing_install_package()` for known optional harness hints.
4. `omnigent.runtime.harnesses` registers `harness_modules()`.
5. Runner launch paths consult `spawn_env_builders()` for contributed headless
harnesses.
6. Host readiness uses `harness_install_keys()` and `install_specs()` to gate
CLI-backed contributed harnesses on their binary.
7. The server exposes `GET /v1/harnesses` from `harness_catalog()`.
8. The web UI merges `/v1/harnesses` into harness picker surfaces.
## Minimal Headless Harness Checklist
For a non-native harness:
- Create a separate package, for example `omnigent-foo`.
- Add the `omnigent.community.harness` entry point.
- Implement `get_contribution()`.
- Fill `valid_harnesses`, `harness_modules`, and `aliases`.
- Add `install_specs` and `harness_install_keys` if the harness needs a CLI.
- Add `spawn_env_builders` if the harness needs spec-derived env vars.
- Add `missing_install_package` entries in core if the harness id should produce
a targeted install hint before the plugin is installed.
- Move harness implementation modules into the plugin package.
- Remove the harness id and module from the built-in contribution.
## Native TUI Harnesses
Community native terminal harnesses are not supported by this interface yet.
Core native harnesses still use internal registry metadata, but the runner,
chat-resume, CLI-command, interrupt/stop, and built-in agent seeding paths are
not pluggable. Community plugins that set `native_harnesses` or `native_agents`
are rejected at load time until those lifecycle hooks are wired end to end.
## Import Rules
Entry-point loading happens early and can happen while other core modules are
still initializing. Plugin `plugin.py` should keep top-level imports light:
- safe: `omnigent.harness_plugins`, `omnigent.harness_install_spec`, constants,
stdlib;
- risky: `omnigent.onboarding.*`, `omnigent.cli`, server modules, runner modules,
or anything that imports `omnigent.harness_aliases`.
Put heavy imports inside the callable that needs them. For example, a spawn-env
builder may import provider/runtime helpers inside `build_spawn_env()`, but
`get_contribution()` should not need onboarding.
## Local Demo Commands
Sibling checkout demo:
```bash
cd /path/to/omnigent-oss-2
uv pip install -e .
uv pip install -e ../omnigent-foo
uv run python -c "from omnigent.harness_plugins import valid_harnesses; print('foo' in valid_harnesses())"
uv run python -c "from omnigent.runtime.harnesses import _HARNESS_MODULES; print(_HARNESS_MODULES['foo'])"
```
If the plugin dependency still points at a published or wrong local `omnigent`,
use the sibling source override in the plugin `pyproject.toml`:
```toml
[tool.uv.sources]
omnigent = { path = "../omnigent-oss-2", editable = true }
```
For published packages, remove local source overrides and publish both
distributions with compatible versions.
## Tests To Add For Each Split Harness
- Core registry excludes the optional harness by default.
- Core validation/error messages suggest the optional package.
- Installing or faking the entry point adds `valid_harnesses`, aliases, install
specs, and harness modules.
- Readiness/setup tests isolate core-only behavior by stubbing entry-point
discovery when the optional package is installed in the dev environment.
- Two community plugins cannot claim the same harness spelling, alias, or
install key.