diff --git a/agentd/__init__.py b/agentd/__init__.py new file mode 100644 index 0000000..6b2926d --- /dev/null +++ b/agentd/__init__.py @@ -0,0 +1,4 @@ +"""plane-agentd: Plane-driven dispatcher for CLI agents (see docs/design.md).""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent diff --git a/agentd/config.py b/agentd/config.py new file mode 100644 index 0000000..0ff4c59 --- /dev/null +++ b/agentd/config.py @@ -0,0 +1,183 @@ +"""Load and validate the agentd YAML configuration. + +The config only ever holds Vault *key names* (`token_key`). The token itself is read from the environment +variable of the same name, which `agent-access.py exec --secret-env KEY=KEY` injects into the process.""" +import json +import os +from pathlib import Path + +import yaml + +from . import ROOT + +SCHEMA_PATH = ROOT / "config" / "schema.json" +DEFAULT_PATHS = (ROOT / "config" / "agentd.local.yaml", ROOT / "config" / "agentd.yaml") + + +class ConfigError(Exception): + pass + + +# ---------------------------------------------------------------- minimal JSON-schema validator (fallback) +_TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)} + + +def _type_ok(value, name): + if name == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if name == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + return isinstance(value, _TYPES[name]) + + +def mini_validate(value, schema, root=None, path="$"): + """Subset of draft-07: $ref (local), type, enum, required, properties, additionalProperties, + items, minItems, minProperties, minimum. Returns a list of error strings.""" + root = root or schema + if "$ref" in schema: + node = root + for part in schema["$ref"].lstrip("#/").split("/"): + node = node[part] + return mini_validate(value, node, root, path) + errs = [] + types = schema.get("type") + if types: + types = [types] if isinstance(types, str) else types + if not any(_type_ok(value, t) for t in types): + return ["%s: expected %s, got %s" % (path, "/".join(types), type(value).__name__)] + if "enum" in schema and value not in schema["enum"]: + errs.append("%s: %r not one of %s" % (path, value, schema["enum"])) + if "minimum" in schema and _type_ok(value, "number") and value < schema["minimum"]: + errs.append("%s: %r < minimum %s" % (path, value, schema["minimum"])) + if isinstance(value, dict): + for key in schema.get("required", []): + if key not in value: + errs.append("%s: missing required '%s'" % (path, key)) + if len(value) < schema.get("minProperties", 0): + errs.append("%s: needs at least %d entries" % (path, schema["minProperties"])) + props = schema.get("properties", {}) + extra = schema.get("additionalProperties", True) + for key, sub in value.items(): + if key in props: + errs += mini_validate(sub, props[key], root, "%s.%s" % (path, key)) + elif extra is False: + errs.append("%s: unknown key '%s'" % (path, key)) + elif isinstance(extra, dict): + errs += mini_validate(sub, extra, root, "%s.%s" % (path, key)) + if isinstance(value, list): + if len(value) < schema.get("minItems", 0): + errs.append("%s: needs at least %d items" % (path, schema["minItems"])) + if "items" in schema: + for i, item in enumerate(value): + errs += mini_validate(item, schema["items"], root, "%s[%d]" % (path, i)) + return errs + + +def schema_errors(data, schema): + try: + import jsonschema + except ImportError: + return mini_validate(data, schema) + validator = jsonschema.Draft7Validator(schema) + return ["$%s: %s" % ("".join(".%s" % p if isinstance(p, str) else "[%d]" % p for p in e.absolute_path), e.message) + for e in validator.iter_errors(data)] + + +def semantic_errors(data): + """Cross-references the schema cannot express.""" + errs = [] + terms, execs = data.get("terminals", {}), data.get("executors", {}) + for tname, t in terms.items(): + for ex in t.get("executors", []): + if ex not in execs: + errs.append("terminals.%s: unknown executor '%s'" % (tname, ex)) + if t.get("transport") == "warpgate-ssh": + if t.get("os") != "windows" or t.get("launch") != "schtasks": + errs.append("terminals.%s: warpgate-ssh currently supports only os=windows, launch=schtasks" % tname) + if t.get("target") != "win10": + errs.append("terminals.%s: warpgate-ssh helper scripts only reach target 'win10' for now" % tname) + if t.get("transport") == "local" and t.get("os") != "linux": + errs.append("terminals.%s: local transport supports only os=linux" % tname) + for ename, e in execs.items(): + adapter = e.get("adapter", ename) + if adapter not in ("codex", "claude-code", "shell"): + errs.append("executors.%s: no adapter '%s' (set 'adapter')" % (ename, adapter)) + cli = data.get("agentd", {}).get("cli_terminal") + if cli and cli not in terms: + errs.append("agentd.cli_terminal: unknown terminal '%s'" % cli) + for pname, p in data.get("projects", {}).items(): + routing = p.get("routing", {}) + for user, tname in routing.get("by_assignee", {}).items(): + if tname not in terms: + errs.append("projects.%s.routing.by_assignee.%s: unknown terminal '%s'" % (pname, user, tname)) + for tname, ex in routing.get("default_executor", {}).items(): + if tname not in terms: + errs.append("projects.%s.routing.default_executor: unknown terminal '%s'" % (pname, tname)) + elif ex not in terms[tname].get("executors", []): + errs.append("projects.%s.routing.default_executor.%s: '%s' not in that terminal's executors" % (pname, tname, ex)) + for label, route in routing.get("by_label", {}).items(): + if "executor" in route and route["executor"] not in execs: + errs.append("projects.%s.routing.by_label.%s: unknown executor '%s'" % (pname, label, route["executor"])) + backoff = p.get("retry", {}).get("backoff_minutes") + if backoff is not None and not backoff: + errs.append("projects.%s.retry.backoff_minutes: empty" % pname) + return errs + + +def validate(data, schema=None): + if schema is None: + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + errs = schema_errors(data, schema) + return errs or semantic_errors(data) + + +class Config: + def __init__(self, data, path=None): + self.data = data + self.path = path + self.plane = data["plane"] + self.terminals = data["terminals"] + self.executors = data["executors"] + self.projects = data["projects"] + self.agentd = data.get("agentd", {}) + + @property + def state_dir(self): + p = Path(os.path.expanduser(self.agentd.get("state_dir", "state"))) + return p if p.is_absolute() else ROOT / p + + def helpers_dir(self, terminal): + d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or str(ROOT / "tools") + return Path(os.path.expanduser(d)) + + def token_env(self, terminal): + """Environment variable that carries the terminal's Plane token (== the Vault key name).""" + return self.terminals[terminal]["token_key"] + + def token(self, terminal): + return os.environ.get(self.token_env(terminal)) + + def secret_env_args(self): + """`--secret-env` arguments for agent-access.py exec, one per distinct token key.""" + keys = sorted({t["token_key"] for t in self.terminals.values()}) + return ["--secret-env %s=%s" % (k, k) for k in keys] + + def project(self, name): + if name not in self.projects: + raise ConfigError("unknown project %s (have %s)" % (name, ", ".join(self.projects))) + return self.projects[name] + + +def load(path=None): + if path is None: + env = os.environ.get("AGENTD_CONFIG") + candidates = [Path(env)] if env else list(DEFAULT_PATHS) + path = next((p for p in candidates if p.exists()), None) + if path is None: + raise ConfigError("no config found (tried %s); copy config/agentd.example.yaml" % ", ".join(map(str, candidates))) + path = Path(path) + data = yaml.safe_load(path.read_text(encoding="utf-8")) + errs = validate(data) + if errs: + raise ConfigError("%s is invalid:\n " % path + "\n ".join(errs)) + return Config(data, path) diff --git a/config/agentd.example.yaml b/config/agentd.example.yaml new file mode 100644 index 0000000..d0db698 --- /dev/null +++ b/config/agentd.example.yaml @@ -0,0 +1,89 @@ +# plane-agentd example configuration (design.md ยง5). Values that are secrets are *Vault key names* only; +# agentd reads the token from the environment variable of the same name, injected by +# agent-access.py exec --secret-env PLANE_WINDOW10_API_TOKEN=PLANE_WINDOW10_API_TOKEN ... +# Copy to config/agentd.local.yaml (git-ignored) or point AGENTD_CONFIG at your file. +plane: {base_url: https://plane.kappiee.com, workspace: kappiee} +vault: {client: ~/.local/share/server-access-client/agent-access.py, config: https://nas.tail9531b.ts.net:8443/identity/agent.json} + +agentd: + state_dir: state # relative to the repo root; JSON state, logs, heartbeat, local run dirs + helpers_dir: /home/ubuntu/Documents/wjj/project/gwent-design/tools # win10-ps.sh / win10-put.sh + interval: 120 # seconds between ticks in --loop mode + max_rounds_per_thread: 8 # rebuild the executor thread (with a handoff summary) after this many rounds + comment_max_chars: 6000 + cli_terminal: oregon # identity used by `agentd new/comment/state/assign` + +terminals: + win10: + transport: warpgate-ssh + target: win10 + os: windows + launch: schtasks + repo: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent' + run_root: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent\runtime\agentd' + bins: {codex: 'C:\Users\Administrator\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe'} + plane_user: window10 + token_key: PLANE_WINDOW10_API_TOKEN + concurrency: 1 + busy_processes: [codex] # do not start while a codex.exe we did not start is running + capabilities: [android, game] + executors: [codex] + oregon: + transport: local + os: linux + launch: subprocess + repo: /home/ubuntu/Documents/wjj/project/gwent + plane_user: oregon + token_key: PLANE_OREGON_API_TOKEN + concurrency: 2 + bins: {claude: /home/ubuntu/.local/bin/claude} # cron has a minimal PATH + executors: [claude-code, codex, shell] + +executors: + codex: + adapter: codex + run: "codex exec --json -C '{repo}' -s workspace-write -m {model} -c 'model_reasoning_effort=\"{effort}\"' {tier} -o '{out}' -" + resume: "codex exec resume {thread} --json -m {model} -c 'model_reasoning_effort=\"{effort}\"' {tier} -o '{out}' -" + tier_arg: "-c 'service_tier=\"{tier}\"'" + thread_from: events.jsonl:thread.started.thread_id + last_message: '{out}' + models: [gpt-6.1-sol, gpt-6-astra] + default_effort: medium + claude-code: + adapter: claude-code + run: "claude -p --model {model} --output-format json --permission-mode {perm}" + resume: "claude -p --resume {thread} --model {model} --output-format json --permission-mode {perm}" + permission_mode: acceptEdits + thread_from: json:session_id + last_message: json:result + models: [sonnet, opus, haiku] + shell: + adapter: shell + run: '{script}' + resume: false + +projects: + GWENT: + id: 79cb5a60-aa4f-4138-9864-1dd70426e665 + key_prefix: GW + webhook_secret_key: PLANE_WEBHOOK_GWENT + states: {backlog: Backlog, todo: Todo, running: In Progress, waiting: Waiting, review: Review, done: Done} + reviewer: oregon # Plane user that gets [review] items + answerer: oregon # Plane user that gets [question]/[blocked] and failures (default: reviewer) + protocol_file: /home/ubuntu/Documents/wjj/project/gwent/design/board-protocol.md + routing: + by_assignee: {window10: win10, oregon: oregon} + by_label: + 'model:astra': {model: gpt-6-astra, effort: high} + 'model:sol': {model: gpt-6.1-sol, tier: fast} + 'model:opus': {model: opus} + 'exec:claude': {executor: claude-code} + 'needs-game': {serialize_group: game} + default_executor: {win10: codex, oregon: claude-code} + retry: + transient_max: 3 + backoff_minutes: [2, 5, 10] + # transient_patterns / env_patterns default to the built-in lists in agentd/core.py + timeout_hours: 4 + max_rounds_per_thread: 8 + notify: {ntfy_topic: gwent, 'on': [waiting, review, failed]} # phase 3, not used yet; 'on' must be quoted (YAML 1.1 boolean) diff --git a/config/schema.json b/config/schema.json new file mode 100644 index 0000000..9e41c4c --- /dev/null +++ b/config/schema.json @@ -0,0 +1,145 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "plane-agentd configuration", + "type": "object", + "required": ["plane", "terminals", "executors", "projects"], + "additionalProperties": false, + "properties": { + "plane": { + "type": "object", + "required": ["base_url", "workspace"], + "additionalProperties": false, + "properties": { + "base_url": {"type": "string"}, + "workspace": {"type": "string"}, + "timeout": {"type": "integer", "minimum": 1} + } + }, + "vault": { + "type": "object", + "additionalProperties": false, + "properties": { + "client": {"type": "string"}, + "config": {"type": "string"} + } + }, + "agentd": { + "type": "object", + "additionalProperties": false, + "properties": { + "state_dir": {"type": "string"}, + "helpers_dir": {"type": "string"}, + "interval": {"type": "integer", "minimum": 10}, + "max_rounds_per_thread": {"type": "integer", "minimum": 1}, + "comment_max_chars": {"type": "integer", "minimum": 500}, + "cli_terminal": {"type": "string"} + } + }, + "terminals": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/definitions/terminal"}}, + "executors": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/definitions/executor"}}, + "projects": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/definitions/project"}} + }, + "definitions": { + "strings": {"type": "array", "items": {"type": "string"}}, + "terminal": { + "type": "object", + "required": ["transport", "os", "launch", "repo", "plane_user", "token_key", "executors"], + "additionalProperties": false, + "properties": { + "transport": {"enum": ["local", "warpgate-ssh"]}, + "target": {"type": "string"}, + "os": {"enum": ["linux", "windows"]}, + "launch": {"enum": ["subprocess", "nohup", "schtasks"]}, + "repo": {"type": "string"}, + "plane_user": {"type": "string"}, + "token_key": {"type": "string"}, + "concurrency": {"type": "integer", "minimum": 1}, + "capabilities": {"$ref": "#/definitions/strings"}, + "executors": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "bins": {"type": "object", "additionalProperties": {"type": "string"}}, + "run_root": {"type": "string"}, + "busy_processes": {"$ref": "#/definitions/strings"}, + "helpers_dir": {"type": "string"} + } + }, + "executor": { + "type": "object", + "required": ["run"], + "additionalProperties": false, + "properties": { + "adapter": {"enum": ["codex", "claude-code", "shell"]}, + "run": {"type": "string"}, + "resume": {"type": ["string", "boolean"]}, + "thread_from": {"type": "string"}, + "last_message": {"type": "string"}, + "models": {"$ref": "#/definitions/strings"}, + "default_effort": {"type": "string"}, + "permission_mode": {"type": "string"}, + "tier_arg": {"type": "string"}, + "script": {"type": "string"} + } + }, + "route": { + "type": "object", + "additionalProperties": false, + "properties": { + "executor": {"type": "string"}, + "model": {"type": "string"}, + "effort": {"type": "string"}, + "tier": {"type": "string"}, + "serialize_group": {"type": "string"}, + "script": {"type": "string"}, + "timeout_hours": {"type": "number", "minimum": 0} + } + }, + "project": { + "type": "object", + "required": ["id", "states", "reviewer", "routing"], + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "key_prefix": {"type": "string"}, + "webhook_secret_key": {"type": "string"}, + "states": { + "type": "object", + "required": ["todo", "running", "waiting", "review", "done"], + "additionalProperties": {"type": "string"} + }, + "reviewer": {"type": "string"}, + "answerer": {"type": "string"}, + "protocol_file": {"type": "string"}, + "intro": {"type": "string"}, + "routing": { + "type": "object", + "required": ["by_assignee"], + "additionalProperties": false, + "properties": { + "by_assignee": {"type": "object", "minProperties": 1, "additionalProperties": {"type": "string"}}, + "by_label": {"type": "object", "additionalProperties": {"$ref": "#/definitions/route"}}, + "default_executor": {"type": "object", "additionalProperties": {"type": "string"}} + } + }, + "retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "transient_max": {"type": "integer", "minimum": 0}, + "backoff_minutes": {"type": "array", "items": {"type": "number", "minimum": 0}, "minItems": 1}, + "transient_patterns": {"$ref": "#/definitions/strings"}, + "env_patterns": {"$ref": "#/definitions/strings"} + } + }, + "timeout_hours": {"type": "number", "minimum": 0}, + "max_rounds_per_thread": {"type": "integer", "minimum": 1}, + "notify": { + "type": "object", + "additionalProperties": false, + "properties": { + "ntfy_topic": {"type": "string"}, + "on": {"$ref": "#/definitions/strings"} + } + } + } + } + } +}