Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[schema]"

- name: Validate plugin config example
run: |
python validation/scripts/validate_plugin_config.py

- name: Validate all example POP objects
run: |
for f in examples/*.json; do
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ build/
dist/
.pytest_cache/
demos/generated/
plugin_config.json
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ Start here:
This dry-run demo shows 3 portable persona objects projected into 3
CrewAI-style runtime roles.

## Plugin Discovery

The demo workflow can load registry extensions from builtin modules and
external plugin packages. By default, the runtime looks for a local
`plugin_config.json` file at the repository root if present. An example
template is available at [`plugin_config.example.json`](plugin_config.example.json),
and the config shape is defined by
[`plugin_config.schema.json`](plugin_config.schema.json).
The example config is also validated in CI so schema drift is caught during PR review.

For temporary overrides, you can also point discovery at external plugin
packages with `POP_PLUGIN_CONFIG_FILE`, `POP_PLUGIN_PACKAGES`, and
`POP_PLUGIN_PACKAGE_PATHS`.

If you use file-based plugin config validation outside this repository,
install the optional schema dependency:

```bash
pip install "pop-persona[schema]"
```

## Persona Layer Diagram

```mermaid
Expand Down
168 changes: 156 additions & 12 deletions demos/discovery.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
from __future__ import annotations

import importlib
import importlib.util
import json
import os
import pkgutil
import sys
from dataclasses import dataclass
from pathlib import Path
from types import ModuleType
from typing import Any

PROJECT_ROOT = Path(__file__).resolve().parents[1]
PLUGIN_PACKAGES_ENV = "POP_PLUGIN_PACKAGES"
PLUGIN_PACKAGE_PATHS_ENV = "POP_PLUGIN_PACKAGE_PATHS"
PLUGIN_CONFIG_FILE_ENV = "POP_PLUGIN_CONFIG_FILE"
DEFAULT_PLUGIN_CONFIG_PATH = PROJECT_ROOT / "plugin_config.json"
PLUGIN_CONFIG_SCHEMA_PATH = PROJECT_ROOT / "plugin_config.schema.json"


@dataclass(frozen=True)
class PluginDiscoveryConfig:
config_path: Path | None
package_names: tuple[str, ...]
package_paths: tuple[Path, ...]


def split_package_names(value: str) -> tuple[str, ...]:
Expand All @@ -23,6 +38,135 @@ def split_search_paths(value: str) -> tuple[Path, ...]:
)


def unique_strings(values: tuple[str, ...]) -> tuple[str, ...]:
ordered: list[str] = []
seen: set[str] = set()
for value in values:
if value and value not in seen:
ordered.append(value)
seen.add(value)
return tuple(ordered)


def unique_paths(values: tuple[Path, ...]) -> tuple[Path, ...]:
ordered: list[Path] = []
seen: set[Path] = set()
for value in values:
if value not in seen:
ordered.append(value)
seen.add(value)
return tuple(ordered)


def resolve_plugin_config_path(config_file: str | Path | None = None) -> Path | None:
if config_file is not None:
candidate = Path(config_file).expanduser().resolve()
if not candidate.exists():
raise FileNotFoundError(f"Plugin config file does not exist: {candidate}")
return candidate

env_value = os.environ.get(PLUGIN_CONFIG_FILE_ENV, "").strip()
if env_value:
candidate = Path(env_value).expanduser().resolve()
if not candidate.exists():
raise FileNotFoundError(f"Plugin config file does not exist: {candidate}")
return candidate

if DEFAULT_PLUGIN_CONFIG_PATH.exists():
return DEFAULT_PLUGIN_CONFIG_PATH

return None


def resolve_relative_path(value: str, base_dir: Path) -> Path:
path = Path(value).expanduser()
if path.is_absolute():
return path.resolve()
return (base_dir / path).resolve()


def normalize_plugin_paths(
payload: dict[str, Any],
base_dir: Path,
) -> tuple[Path, ...]:
paths: list[Path] = []
for raw_path in payload.get("plugin_package_paths", ()):
if str(raw_path).strip():
paths.append(resolve_relative_path(str(raw_path), base_dir))

for entry in payload.get("plugin_paths", ()):
if isinstance(entry, str):
if entry.strip():
paths.append(resolve_relative_path(entry, base_dir))
continue
if isinstance(entry, dict):
raw_path = str(entry.get("path", "")).strip()
if raw_path:
paths.append(resolve_relative_path(raw_path, base_dir))

return unique_paths(tuple(paths))


def normalize_plugin_packages(payload: dict[str, Any]) -> tuple[str, ...]:
package_names: list[str] = []
for package_name in payload.get("plugin_packages", ()):
normalized = str(package_name).strip()
if normalized:
package_names.append(normalized)

for entry in payload.get("plugin_paths", ()):
if isinstance(entry, dict):
package_name = str(entry.get("package_name", "")).strip()
if package_name:
package_names.append(package_name)

return unique_strings(tuple(package_names))


def load_plugin_config(
config_file: str | Path | None = None,
) -> PluginDiscoveryConfig:
config_path = resolve_plugin_config_path(config_file)
if config_path is None:
return PluginDiscoveryConfig(None, (), ())

payload = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("Plugin config must be a JSON object.")
validate_plugin_config_payload(payload)

base_dir = config_path.parent
package_paths = normalize_plugin_paths(payload, base_dir)
package_names = normalize_plugin_packages(payload)
return PluginDiscoveryConfig(config_path, package_names, package_paths)


def load_plugin_config_schema() -> dict[str, Any]:
return json.loads(PLUGIN_CONFIG_SCHEMA_PATH.read_text(encoding="utf-8"))


def validation_error_path(error: Any) -> str:
parts = [str(part) for part in getattr(error, "absolute_path", ())]
return ".".join(parts) if parts else "<root>"


def validate_plugin_config_payload(payload: dict[str, Any]) -> None:
try:
import jsonschema
except ModuleNotFoundError as exc:
raise RuntimeError(
"Plugin config validation requires `jsonschema`. "
"Install `pop-persona[schema]` or add `jsonschema` to your environment."
) from exc

schema = load_plugin_config_schema()
try:
jsonschema.validate(instance=payload, schema=schema)
except jsonschema.ValidationError as exc:
path = validation_error_path(exc)
raise ValueError(f"Invalid plugin config at {path}: {exc.message}") from exc


def configure_plugin_search_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]:
configured: list[Path] = []
for path in paths:
Expand All @@ -35,21 +179,21 @@ def configure_plugin_search_paths(paths: tuple[Path, ...]) -> tuple[Path, ...]:
return tuple(configured)


def configured_package_names(
def configured_plugin_sources(
default_packages: tuple[str, ...] = ("demos",),
extra_packages: tuple[str, ...] = (),
) -> tuple[str, ...]:
plugin_paths = split_search_paths(os.environ.get(PLUGIN_PACKAGE_PATHS_ENV, ""))
configure_plugin_search_paths(plugin_paths)
config_file: str | Path | None = None,
) -> PluginDiscoveryConfig:
file_config = load_plugin_config(config_file)
env_paths = split_search_paths(os.environ.get(PLUGIN_PACKAGE_PATHS_ENV, ""))
configured_paths = configure_plugin_search_paths(
unique_paths((*file_config.package_paths, *env_paths))
)
env_packages = split_package_names(os.environ.get(PLUGIN_PACKAGES_ENV, ""))

ordered_packages: list[str] = []
seen: set[str] = set()
for package_name in (*default_packages, *extra_packages, *env_packages):
if package_name and package_name not in seen:
ordered_packages.append(package_name)
seen.add(package_name)
return tuple(ordered_packages)
package_names = unique_strings(
(*default_packages, *extra_packages, *file_config.package_names, *env_packages)
)
return PluginDiscoveryConfig(file_config.config_path, package_names, configured_paths)


def import_matching_modules(
Expand Down
3 changes: 2 additions & 1 deletion demos/persona_workflow_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,8 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Run the persona workflow demo with dynamic task input. "
"External plugin packages can be added with "
"External plugin packages can be added with plugin_config.json or "
"POP_PLUGIN_CONFIG_FILE, and overridden with "
"POP_PLUGIN_PACKAGES and POP_PLUGIN_PACKAGE_PATHS."
)
)
Expand Down
30 changes: 23 additions & 7 deletions demos/task_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class TaskTypeDefinition:
STAGE_HANDLER_REGISTRY: dict[str, StageHandlerDefinition] = {}
TASK_REGISTRY: dict[str, TaskTypeDefinition] = {}
LOADED_REGISTRY_PACKAGES: tuple[str, ...] = ()
LOADED_PLUGIN_SEARCH_PATHS: tuple[Path, ...] = ()
LOADED_PLUGIN_CONFIG_PATH: Path | None = None


def register_persona(definition: PersonaDefinition) -> None:
Expand Down Expand Up @@ -110,6 +112,14 @@ def loaded_registry_packages() -> tuple[str, ...]:
return LOADED_REGISTRY_PACKAGES


def loaded_plugin_search_paths() -> tuple[Path, ...]:
return LOADED_PLUGIN_SEARCH_PATHS


def loaded_plugin_config_path() -> Path | None:
return LOADED_PLUGIN_CONFIG_PATH


def register_task_type(definition: TaskTypeDefinition) -> None:
TASK_REGISTRY[definition.task_type] = definition

Expand Down Expand Up @@ -196,30 +206,32 @@ def build_deliverable(task_type: str, context: Any) -> dict[str, Any]:
def load_registry(
extra_packages: tuple[str, ...] = (),
reset: bool = True,
config_file: str | Path | None = None,
) -> tuple[str, ...]:
from demos.discovery import collect_module_exports, configured_package_names
from demos.discovery import collect_module_exports, configured_plugin_sources

package_names = configured_package_names(
plugin_sources = configured_plugin_sources(
default_packages=("demos",),
extra_packages=extra_packages,
config_file=config_file,
)
if reset:
reset_registry()

handlers = collect_module_exports(
package_names,
plugin_sources.package_names,
"STAGE_HANDLER_DEFINITIONS",
exact_names=("stage_handlers",),
prefixes=("stage_handlers_",),
)
personas = collect_module_exports(
package_names,
plugin_sources.package_names,
"PERSONA_DEFINITIONS",
exact_names=("persona_definitions",),
prefixes=("persona_definitions_",),
)
task_types = collect_module_exports(
package_names,
plugin_sources.package_names,
"TASK_TYPE_DEFINITIONS",
exact_names=("task_types",),
prefixes=("task_types_",),
Expand All @@ -232,8 +244,12 @@ def load_registry(
for task_type in task_types:
register_task_type(task_type)
global LOADED_REGISTRY_PACKAGES
LOADED_REGISTRY_PACKAGES = package_names
return package_names
global LOADED_PLUGIN_SEARCH_PATHS
global LOADED_PLUGIN_CONFIG_PATH
LOADED_REGISTRY_PACKAGES = plugin_sources.package_names
LOADED_PLUGIN_SEARCH_PATHS = plugin_sources.package_paths
LOADED_PLUGIN_CONFIG_PATH = plugin_sources.config_path
return plugin_sources.package_names


def load_builtin_registry() -> tuple[str, ...]:
Expand Down
11 changes: 11 additions & 0 deletions plugin_config.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "./plugin_config.schema.json",
"plugin_paths": [
{
"path": "/absolute/path/to/pop_plugin_package_root",
"package_name": "pop_demo_plugins"
}
],
"plugin_packages": [],
"plugin_package_paths": []
}
55 changes: 55 additions & 0 deletions plugin_config.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/joy7758/persona-object-protocol/plugin_config.schema.json",
"title": "POP Plugin Discovery Config",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"minLength": 1
},
"plugin_paths": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"minLength": 1
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1
},
"package_name": {
"type": "string",
"minLength": 1
}
},
"required": [
"path"
]
}
]
}
},
"plugin_packages": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"plugin_package_paths": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
}
Loading
Loading