diff --git a/harness/README.md b/harness/README.md new file mode 100644 index 0000000..18f72f2 --- /dev/null +++ b/harness/README.md @@ -0,0 +1,19 @@ +Minimal Harness for revfactory/harness + +Quick start + +1. Run the harness: + +```bash +python3 harness/harness.py --config harness/config/agents.json +``` + +What this does + +- Loads `harness/config/agents.json` which declares the agent team and simple parameters. +- Dynamically imports example agent modules from `harness/agents/` and calls their `run(config)` function. + +Next steps + +- Replace or extend the example agents in `harness/agents/` with real implementations. +- Hook the harness into CI or orchestration for automated runs. diff --git a/harness/__init__.py b/harness/__init__.py new file mode 100644 index 0000000..c2f1b92 --- /dev/null +++ b/harness/__init__.py @@ -0,0 +1 @@ +"""harness package init""" diff --git a/harness/__pycache__/__init__.cpython-312.pyc b/harness/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..114ab04 Binary files /dev/null and b/harness/__pycache__/__init__.cpython-312.pyc differ diff --git a/harness/__pycache__/harness.cpython-312.pyc b/harness/__pycache__/harness.cpython-312.pyc new file mode 100644 index 0000000..1cb6d5e Binary files /dev/null and b/harness/__pycache__/harness.cpython-312.pyc differ diff --git a/harness/__pycache__/logging_setup.cpython-312.pyc b/harness/__pycache__/logging_setup.cpython-312.pyc new file mode 100644 index 0000000..59c31e8 Binary files /dev/null and b/harness/__pycache__/logging_setup.cpython-312.pyc differ diff --git a/harness/agent_team.md b/harness/agent_team.md new file mode 100644 index 0000000..948f6b7 --- /dev/null +++ b/harness/agent_team.md @@ -0,0 +1,34 @@ +# Agent team design for this domain + + +Team overview + +- **Orchestrator**: coordinates the workflow, schedules agents, and aggregates results. +- **RepoInspector**: inspects repository structure and key files (README, LICENSE, CHANGELOG, docs). +- **DependencyChecker**: finds dependency manifests (requirements.txt, package.json, pyproject.toml) and reports potential issues. +- **ChangelogAuditor**: parses `CHANGELOG.md` and release notes for recency and formatting hints. +- **DocsChecker**: validates presence of key docs (quickstart, experimental notes) and extracts TODOs. +- **ReleasePlanner**: suggests a next-release checklist based on findings. +- **LocalTester**: runs lightweight smoke checks (existence of test commands, basic lint heuristics). +- **Strategist / ContentGenerator / QA**: as previously defined to plan, generate artifacts, and validate. + +Interaction patterns + +- Orchestrator invokes Scout -> Auditor -> Strategist -> ContentGenerator -> QA in a pipeline for each work item. +- Agents exchange structured JSON payloads; each agent reads the previous step's output and appends `result` and `metadata`. +- The Orchestrator keeps an audit log and can re-run or branch tasks for parallel experimentation. + +Data contracts + +- Input: {"id": "", "payload": {...}, "history": [...]} +- Agent output: {"id": "", "result": {...}, "metadata": {"agent":"","timestamp":"..."}} + +Failure and retries + +- Agents should return exit codes and error objects; Orchestrator retries transient failures with exponential backoff. +- Critical failures are surfaced to a human reviewer via the Orchestrator's notification hook. + +Extensibility + +- Add new agents by registering them in the config and implementing `run(config, input)`. +- Support for remote agents (HTTP/gRPC) can be added by providing an adapter layer behind the same interface. diff --git a/harness/agents/__init__.py b/harness/agents/__init__.py new file mode 100644 index 0000000..1ec7dea --- /dev/null +++ b/harness/agents/__init__.py @@ -0,0 +1 @@ +# agents package diff --git a/harness/agents/__pycache__/__init__.cpython-312.pyc b/harness/agents/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..502f3ab Binary files /dev/null and b/harness/agents/__pycache__/__init__.cpython-312.pyc differ diff --git a/harness/agents/__pycache__/example_agents.cpython-312.pyc b/harness/agents/__pycache__/example_agents.cpython-312.pyc new file mode 100644 index 0000000..7f66474 Binary files /dev/null and b/harness/agents/__pycache__/example_agents.cpython-312.pyc differ diff --git a/harness/agents/__pycache__/repo_agents.cpython-312.pyc b/harness/agents/__pycache__/repo_agents.cpython-312.pyc new file mode 100644 index 0000000..08c7f60 Binary files /dev/null and b/harness/agents/__pycache__/repo_agents.cpython-312.pyc differ diff --git a/harness/agents/example_agents.py b/harness/agents/example_agents.py new file mode 100644 index 0000000..027be1d --- /dev/null +++ b/harness/agents/example_agents.py @@ -0,0 +1,49 @@ + +"""Example agent implementations used by the harness for demonstration. + +Each callable accepts `(params, input_payload)` and returns a serializable result. +""" +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def orchestrator(params, input_payload): + logger.debug("orchestrator start") + return {"note": "orchestrator started", "timestamp": datetime.utcnow().isoformat()} + + +def scout(params, input_payload): + logger.debug("scout start params=%s", params) + seed = params.get("seed", "unknown") + discovered = {"repos": ["/workspaces/harness"], "seed": seed} + logger.debug("scout discovered=%s", discovered) + return {"discovered": discovered, "timestamp": datetime.utcnow().isoformat()} + + +def auditor(params, input_payload): + logger.debug("auditor start") + issues = [] + if "harness" in input_payload.get("payload", {}).get("repo", ""): + issues.append({"type": "info", "message": "harness folder present"}) + logger.debug("auditor issues=%s", issues) + return {"issues": issues, "timestamp": datetime.utcnow().isoformat()} + + +def strategist(params, input_payload): + logger.debug("strategist start") + plan = ["audit", "generate_content", "qa"] + return {"plan": plan, "timestamp": datetime.utcnow().isoformat()} + + +def content_generator(params, input_payload): + logger.debug("content_generator start") + summary = "Generated summary for task" + return {"summary": summary, "timestamp": datetime.utcnow().isoformat()} + + +def qa(params, input_payload): + logger.debug("qa start") + ok = True + return {"ok": ok, "notes": [], "timestamp": datetime.utcnow().isoformat()} diff --git a/harness/agents/repo_agents.py b/harness/agents/repo_agents.py new file mode 100644 index 0000000..64c0c5b --- /dev/null +++ b/harness/agents/repo_agents.py @@ -0,0 +1,102 @@ + +"""Repository-specific agent implementations for the harness. + +Each function receives (params, input_payload) and returns JSON-serializable findings. +""" +import logging +import os +import json +from datetime import datetime + +logger = logging.getLogger(__name__) + + +def _read_file(path): + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + logger.debug("Failed to read file %s", path, exc_info=True) + return None + + +def repo_inspector(params, input_payload): + logger.debug("repo_inspector start") + root = os.getcwd() + files = sorted([f for f in os.listdir(root) if os.path.isfile(f)]) + dirs = sorted([d for d in os.listdir(root) if os.path.isdir(d)]) + important = {"README.md": os.path.exists("README.md"), "CHANGELOG.md": os.path.exists("CHANGELOG.md"), "LICENSE": os.path.exists("LICENSE")} + return {"files": files, "dirs": dirs, "important": important, "timestamp": datetime.utcnow().isoformat()} + + +def dependency_checker(params, input_payload): + logger.debug("dependency_checker start") + roots = {"requirements.txt": os.path.exists("requirements.txt"), "package.json": os.path.exists("package.json"), "pyproject.toml": os.path.exists("pyproject.toml")} + details = {} + if roots.get("requirements.txt"): + content = _read_file("requirements.txt") or "" + details["requirements.txt"] = content[:200] + if roots.get("package.json"): + pj_text = _read_file("package.json") or "{}" + try: + details["package.json"] = json.loads(pj_text) + except Exception: + logger.debug("package.json parse failed", exc_info=True) + details["package.json"] = {} + return {"manifests": roots, "details_preview": details, "timestamp": datetime.utcnow().isoformat()} + + +def changelog_auditor(params, input_payload): + logger.debug("changelog_auditor start") + text = _read_file("CHANGELOG.md") + if not text: + return {"found": False, "issues": ["CHANGELOG.md missing"], "timestamp": datetime.utcnow().isoformat()} + recent_lines = text.strip().splitlines()[:20] + return {"found": True, "recent_preview": "\n".join(recent_lines), "timestamp": datetime.utcnow().isoformat()} + + +def docs_checker(params, input_payload): + logger.debug("docs_checker start") + docs = {} + check_files = ["docs/quickstart.md", "docs/experimental-dependency.md", "README.md", "privacy.html"] + for p in check_files: + docs[p] = os.path.exists(p) + todos = [] + if os.path.isdir("docs"): + for root, _, files in os.walk("docs"): + for f in files: + path = os.path.join(root, f) + content = _read_file(path) or "" + if "TODO" in content: + todos.append({"file": path, "snippet": content[:120]}) + return {"checks": docs, "todos": todos, "timestamp": datetime.utcnow().isoformat()} + + +def release_planner(params, input_payload): + logger.debug("release_planner start") + history = input_payload.get("history", []) + issues = [] + for h in history: + r = h.get("result", {}) + if isinstance(r, dict) and r.get("issues"): + issues.extend(r.get("issues")) + plan = ["bump-version", "update-changelog", "run-tests", "publish"] + if issues: + plan.insert(0, "address-issues") + return {"plan": plan, "issues": issues, "timestamp": datetime.utcnow().isoformat()} + + +def local_tester(params, input_payload): + logger.debug("local_tester start") + hints = {"has_pytest": False, "has_test_script": False} + if os.path.exists("requirements.txt"): + req = _read_file("requirements.txt") or "" + hints["has_pytest"] = "pytest" in req + if os.path.exists("package.json"): + try: + pj = json.loads(_read_file("package.json") or "{}") + scripts = pj.get("scripts", {}) + hints["has_test_script"] = "test" in scripts + except Exception: + logger.debug("package.json parse failed in local_tester", exc_info=True) + return {"hints": hints, "timestamp": datetime.utcnow().isoformat()} diff --git a/harness/config/agents.json b/harness/config/agents.json new file mode 100644 index 0000000..f784af2 --- /dev/null +++ b/harness/config/agents.json @@ -0,0 +1,14 @@ +{ + "agents": [ + {"name": "orchestrator", "module": "harness.agents.example_agents", "callable": "orchestrator", "params": {}}, + {"name": "repo_inspector", "module": "harness.agents.repo_agents", "callable": "repo_inspector", "params": {}}, + {"name": "dependency_checker", "module": "harness.agents.repo_agents", "callable": "dependency_checker", "params": {}}, + {"name": "changelog_auditor", "module": "harness.agents.repo_agents", "callable": "changelog_auditor", "params": {}}, + {"name": "docs_checker", "module": "harness.agents.repo_agents", "callable": "docs_checker", "params": {}}, + {"name": "release_planner", "module": "harness.agents.repo_agents", "callable": "release_planner", "params": {}}, + {"name": "local_tester", "module": "harness.agents.repo_agents", "callable": "local_tester", "params": {}}, + {"name": "strategist", "module": "harness.agents.example_agents", "callable": "strategist", "params": {}}, + {"name": "content", "module": "harness.agents.example_agents", "callable": "content_generator", "params": {}}, + {"name": "qa", "module": "harness.agents.example_agents", "callable": "qa", "params": {}} + ] +} diff --git a/harness/config/harness_defaults.json b/harness/config/harness_defaults.json new file mode 100644 index 0000000..034ceaa --- /dev/null +++ b/harness/config/harness_defaults.json @@ -0,0 +1,23 @@ +{ + "logging": { + "log_file": "harness_rot.log", + "log_level": "INFO", + "log_max_bytes": 0, + "log_backup_count": 0 + }, + "agents_config": "harness/config/agents.json", + "agents": { + "agents": [ + {"name": "orchestrator", "module": "harness.agents.example_agents", "callable": "orchestrator", "params": {}}, + {"name": "repo_inspector", "module": "harness.agents.repo_agents", "callable": "repo_inspector", "params": {}}, + {"name": "dependency_checker", "module": "harness.agents.repo_agents", "callable": "dependency_checker", "params": {}}, + {"name": "changelog_auditor", "module": "harness.agents.repo_agents", "callable": "changelog_auditor", "params": {}}, + {"name": "docs_checker", "module": "harness.agents.repo_agents", "callable": "docs_checker", "params": {}}, + {"name": "release_planner", "module": "harness.agents.repo_agents", "callable": "release_planner", "params": {}}, + {"name": "local_tester", "module": "harness.agents.repo_agents", "callable": "local_tester", "params": {}}, + {"name": "strategist", "module": "harness.agents.example_agents", "callable": "strategist", "params": {}}, + {"name": "content", "module": "harness.agents.example_agents", "callable": "content_generator", "params": {}}, + {"name": "qa", "module": "harness.agents.example_agents", "callable": "qa", "params": {}} + ] + } +} diff --git a/harness/harness.py b/harness/harness.py new file mode 100644 index 0000000..be79714 --- /dev/null +++ b/harness/harness.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Simple harness runner: loads a JSON config and executes agent callables. + +Usage: python3 harness/harness.py --config harness/config/agents.json +""" +import argparse +import importlib +import json +import os +import sys +import traceback +from datetime import datetime + +# Ensure the project root is on sys.path so `import harness.agents...` works +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +# logging will be initialized after parsing CLI args +from harness.logging_setup import init_logging +logger = None + + +def load_config(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def call_agent(agent_conf, payload): + module_name = agent_conf.get("module") + callable_name = agent_conf.get("callable") + params = agent_conf.get("params", {}) + module = importlib.import_module(module_name) + func = getattr(module, callable_name) + start = datetime.utcnow().isoformat() + "Z" + logger.info("Calling agent %s.%s", module_name, callable_name) + try: + result = func(params, payload) + end = datetime.utcnow().isoformat() + "Z" + logger.info("Agent %s finished", agent_conf.get("name")) + return {"agent": agent_conf.get("name"), "start": start, "end": end, "result": result} + except Exception as e: + end = datetime.utcnow().isoformat() + "Z" + tb = traceback.format_exc() + logger.exception("Agent %s raised exception", agent_conf.get("name")) + return { + "agent": agent_conf.get("name"), + "start": start, + "end": end, + "error": {"message": str(e), "type": e.__class__.__name__, "traceback": tb}, + } + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--config", required=False, help="path to agents config JSON") + p.add_argument("--log-file", required=False, help="path to log file (append)") + p.add_argument("--log-level", required=False, default=None, help="logging level, e.g. DEBUG, INFO, WARNING") + p.add_argument("--log-max-bytes", required=False, type=int, default=None, help="max bytes for rotating log (0 disables rotation)") + p.add_argument("--log-backup-count", required=False, type=int, default=None, help="number of rotated backup files to keep") + p.add_argument("--defaults", required=False, help="path to defaults JSON (overrides harness/config/harness_defaults.json)") + args = p.parse_args() + + # load defaults from file (bundle defaults live in harness/config) + defaults_path = args.defaults or os.path.join(os.path.dirname(__file__), "config", "harness_defaults.json") + defaults = {} + if os.path.exists(defaults_path): + try: + defaults = load_config(defaults_path) + except Exception: + # fall back to empty defaults + defaults = {} + + # resolve config path: CLI > defaults > error + config_path = args.config or defaults.get("agents_config") + if not config_path: + raise SystemExit("No agents config provided (use --config or set agents_config in defaults)") + + # resolve logging options: CLI values override defaults + resolved_log_file = args.log_file if args.log_file is not None else defaults.get("logging", {}).get("log_file") + resolved_log_level = (args.log_level or defaults.get("logging", {}).get("log_level", "INFO")).upper() + resolved_max_bytes = args.log_max_bytes if args.log_max_bytes not in (None, 0) else defaults.get("logging", {}).get("log_max_bytes", 0) + resolved_backup_count = args.log_backup_count if args.log_backup_count not in (None, 0) else defaults.get("logging", {}).get("log_backup_count", 0) + + # initialize logging according to resolved values + try: + lvl = getattr(__import__("logging"), resolved_log_level) + except Exception: + lvl = getattr(__import__("logging"), "INFO") + global logger + logger = init_logging(level=lvl, logfile=resolved_log_file, max_bytes=resolved_max_bytes or 0, backup_count=resolved_backup_count or 0) + + if config_path: + conf = load_config(config_path) + else: + # support embedding agents directly in defaults under key 'agents' + conf = defaults.get("agents") or {} + agents = conf.get("agents", []) + + # initial payload + payload = {"id": "task-1", "payload": {"repo": "./"}, "history": []} + + for a in agents: + print(f"Running agent: {a.get('name')}") + out = call_agent(a, payload) + payload["history"].append(out) + # optionally pass the last result as the new payload + payload["payload"][a.get("name")] = out.get("result") + + print("\nRun complete. History:") + print(json.dumps(payload["history"], indent=2)) + + +if __name__ == "__main__": + main() diff --git a/harness/logging_setup.py b/harness/logging_setup.py new file mode 100644 index 0000000..e54c1c2 --- /dev/null +++ b/harness/logging_setup.py @@ -0,0 +1,31 @@ +import logging +import sys +from logging.handlers import RotatingFileHandler + + +def init_logging(level=logging.INFO, logfile: str | None = None, max_bytes: int = 0, backup_count: int = 0): + fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s" + logger = logging.getLogger("harness") + logger.setLevel(level) + + # clear existing handlers + for h in list(logger.handlers): + logger.removeHandler(h) + + # console handler + ch = logging.StreamHandler(sys.stdout) + ch.setLevel(level) + ch.setFormatter(logging.Formatter(fmt)) + logger.addHandler(ch) + + # optional rotating file handler + if logfile: + if max_bytes and max_bytes > 0: + fh = RotatingFileHandler(logfile, mode="a", maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8") + else: + fh = RotatingFileHandler(logfile, mode="a", maxBytes=0, backupCount=0, encoding="utf-8") + fh.setLevel(level) + fh.setFormatter(logging.Formatter(fmt)) + logger.addHandler(fh) + + return logger