diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fdacb7e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: Plugin checks + +"on": + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.11" + - "3.12" + - "3.13" + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + + - uses: actions/setup-node@v7 + with: + node-version: "20" + + - name: Install test dependency + run: python -m pip install "pydantic>=2,<3" + + - name: Arrange Agent Zero plugin layout + run: | + mkdir -p "$RUNNER_TEMP/a0/usr/plugins" + ln -s "$GITHUB_WORKSPACE" "$RUNNER_TEMP/a0/usr/plugins/agent_harness" + + - name: Run Python regression tests + env: + A0_TEST_PROJECT_ROOT: ${{ runner.temp }}/a0 + PYTHONDONTWRITEBYTECODE: "1" + PYTHONPATH: ${{ runner.temp }}/a0 + run: python -m unittest discover -s tests -v + + - name: Check dashboard JavaScript syntax + run: | + node --check webui/harness-store.js + node --check extensions/webui/surfaces_register/_20_register_agent_harness.js + + - name: Check Python syntax + run: python -m compileall -q . diff --git a/.gitignore b/.gitignore index 0515101..e4a80b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +config.json **/__pycache__/ __pycache__/ -*.pyc \ No newline at end of file +*.pyc diff --git a/Install.md b/Install.md deleted file mode 100644 index 78321b3..0000000 --- a/Install.md +++ /dev/null @@ -1,79 +0,0 @@ -# Agent Harness DeerFlow Install - -This file bootstraps the Agent Zero `agent_harness` plugin so it behaves like a DeerFlow-style coding harness instead of just a latent backend module. - -## Goal - -Prepare the local Agent Zero workspace for DeerFlow-style coding runs with: - -- DeerFlow-compatible modes: `flash`, `standard`, `pro`, `ultra` -- DeerFlow-style thread data roots for `workspace`, `uploads`, and `outputs` -- plugin-local DeerFlow core APIs for thread state, uploads, artifacts, and host catalogs -- plugin-local bootstrap and skill-discovery skills -- optional import of DeerFlow public skills into Agent Zero's skill registry - -## Success Criteria - -Consider the harness bootstrap complete when all of the following are true: - -- `usr/plugins/agent_harness/plugin.yaml` exists and the plugin loads -- the plugin ships `skills/public/bootstrap` and `skills/public/find-skills` -- the plugin exposes `deerflow_core`, `thread_data`, `thread_uploads`, and `thread_artifacts` APIs -- `python usr/plugins/agent_harness/scripts/check_deerflow_harness.py` exits successfully -- if a local DeerFlow checkout is available, its public skills can be listed or imported without manual path surgery -- the user receives the exact next step for either verification or skill import - -## Steps - -1. Confirm you are in the Agent Zero repository root. -2. Verify the plugin assets: - - ```bash - python usr/plugins/agent_harness/scripts/check_deerflow_harness.py - ``` - -3. If you have a local DeerFlow checkout and want the upstream public skills available in Agent Zero, import them: - - ```bash - python usr/plugins/agent_harness/scripts/import_deerflow_public_skills.py --source /path/to/deer-flow - ``` - -4. Run the harness tests: - - ```bash - ./.venv/bin/pytest -q tests/test_agent_harness_plugin.py tests/test_harness_*.py - ``` - -5. Start the harness from the UI or via the tool in `pro` or `ultra` mode. -6. If you want to inspect the DeerFlow-style core bridge, call: - - - `/plugins/agent_harness/deerflow_core` - - `/plugins/agent_harness/thread_data` - - `/plugins/agent_harness/thread_uploads` - - `/plugins/agent_harness/thread_artifacts` - -## Recommended Next Commands - -- Verify local harness assets: - - ```bash - python usr/plugins/agent_harness/scripts/check_deerflow_harness.py - ``` - -- Verify thread-data and core bridge coverage: - - ```bash - ./.venv/bin/pytest -q tests/test_harness_workspace.py tests/test_agent_harness_plugin.py - ``` - -- Verify against a DeerFlow checkout: - - ```bash - python usr/plugins/agent_harness/scripts/check_deerflow_harness.py --source /path/to/deer-flow - ``` - -- Import DeerFlow public skills: - - ```bash - python usr/plugins/agent_harness/scripts/import_deerflow_public_skills.py --source /path/to/deer-flow - ``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2a12c2d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 a0-community-plugins contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 9f19d25..4fbef95 100644 --- a/README.md +++ b/README.md @@ -1,450 +1,251 @@ # Agent Harness -`agent_harness` turns Agent Zero into a coding-first, DeerFlow-inspired implementation harness. +Agent Harness adds a coding workflow, safety checkpoints, per-chat workspaces, +and optional parallel task execution to Agent Zero without modifying Agent Zero +core code. -It adds explicit run modes, checkpointed risky actions, thread-scoped workspace data, memory curation, task-graph orchestration, and a DeerFlow-like core bridge over Agent Zero's existing runtime. +The design borrows useful harness ideas from +[ByteDance DeerFlow](https://github.com/bytedance/deer-flow), but this is an +Agent Zero plugin rather than a port of DeerFlow's LangGraph server, gateway, +channel integrations, sandbox providers, or frontend. -## What This Plugin Is +## Pro and Ultra are intentionally different -Agent Harness is the closest DeerFlow-style coding harness that fits naturally inside Agent Zero's plugin architecture. - -It is designed for: - -- deep implementation tasks that benefit from an explicit run lifecycle -- safer autonomous coding with checkpoints before risky actions -- project rule curation instead of silent memory writes -- thread-local uploads, outputs, and scratch workspace data -- structured orchestration for larger coding tasks -- exposing DeerFlow-like backend state without replacing Agent Zero itself - -## What This Plugin Is Not - -This plugin is not a literal port of the full DeerFlow backend. - -Today it does not ship: - -- a standalone LangGraph server -- a separate DeerFlow gateway service -- DeerFlow's channel app layer -- DeerFlow's full sandbox/provider stack - -Instead, it brings DeerFlow's coding-harness ideas into Agent Zero by using Agent Zero's own plugin hooks, API handlers, tools, and UI. - -## Main Capabilities - -- DeerFlow-aligned coding modes: `flash`, `standard`, `pro`, `ultra` -- explicit run state with objective, phase, risk level, failures, verification, and task tracking -- guarded execution with approval checkpoints for risky actions -- manual and queued memory curation for reusable repo rules -- DeerFlow-style thread data roots for `workspace`, `uploads`, and `outputs` -- optional multi-step task-graph orchestration with parallel subagent dispatch in `ultra` -- a DeerFlow-like core facade for thread state, configured models, available skills, and memory status -- dashboard UI for current run, task graph, pending checkpoints, memory queue, and recent accepted rules - -## Quick Start - -1. Enable the plugin in Agent Zero. - - This plugin is not `always_enabled`, so it must be turned on through Agent Zero's plugin controls. It supports global, per-project, and per-agent configuration. - -2. Open the Agent Harness UI. - - The plugin provides: - - - a dashboard for active run state and review queues - - a status strip above the chat input when `show_status_ui` is enabled - - a sidebar entry for quick access - -3. Start a run. - - For most coding tasks, start in `pro`. - - Use `ultra` when the work is large enough to benefit from decomposition and parallel subagents. - -4. Work in chat as usual. +| Mode | Execution model | Planning | Background workers | Use it for | +| --- | --- | --- | --- | --- | +| `flash` | single agent | minimal | none | small, low-risk changes | +| `standard` | single agent | optional | none | ordinary implementation work | +| `pro` | structured single agent | explicit for multi-step or risky work | none | most serious coding tasks | +| `ultra` | task graph plus main agent | mandatory | 1-4 | independent, decomposable work | - The harness injects workflow instructions into the runtime and tracks the run as the agent moves through inspect, plan, implementation, verification, and completion. +`pro` is the recommended default. It keeps inspect, plan, implement, verify, +and completion discipline without paying the coordination cost of worker +agents. -5. Review checkpoints and memory proposals. +Use `ultra` only when the task can be separated into non-overlapping units. +Ultra requires a task graph before implementation and is the only mode allowed +to call `plan`, `dispatch`, `collect`, or `adopt`. Workers have isolated Agent +Zero contexts and harness runs, but they share the same filesystem. A worker +that reaches an approval boundary stops and reports the exact action to the main +chat instead of bypassing the checkpoint. - Risky actions will pause behind a checkpoint. Reusable rules are captured into a review queue instead of being silently persisted. +Legacy saved mode names are normalized automatically: -6. Verify and complete. +- `assist` becomes `flash` +- `build` becomes `pro` +- `surge` becomes `ultra` - The harness expects tests or other verification to be recorded before a run is completed. +## What the plugin adds -## Recommended Mode Selection +- Full workflow instructions injected into Agent Zero's runtime prompt +- Explicit run mode, phase, objective, risk, task, verification, and failure + state +- Action-bound, single-use approval checkpoints +- An Ultra-only task graph with bounded concurrent workers +- Reviewable project, agent, or global harness rules +- Per-chat scratch, upload, and output directories +- A theme-native right-side observability canvas and compact status control + with no runtime CDN dependency +- Approximate completion-token tracking and an optional one-time budget pause -| Mode | Best For | Planning | Subagents | Default Policy | -| --- | --- | --- | --- | --- | -| `flash` | small, low-risk coding tasks | usually skipped | disabled | `subagent_limit=0`, `repair_limit=0` | -| `standard` | moderate single-agent work | optional | disabled | `subagent_limit=0`, `repair_limit=1` | -| `pro` | most serious coding work | recommended for multi-step tasks | disabled | `subagent_limit=0`, `repair_limit=1` | -| `ultra` | larger, multi-file, decomposable work | required before multi-file execution | enabled | `subagent_limit=3`, `repair_limit=3` | +Ambient Assist supplies lightweight inspect-and-verify guidance even when a +formal run has not been started. It can be disabled in plugin settings. -Migration note: +## Installation and updates -- legacy stored values such as `assist`, `build`, and `surge` are normalized to the new DeerFlow-style modes so older saved settings do not break existing chats +Install the plugin through Agent Zero's plugin manager, then enable it globally +or for the desired project or agent. -## Run Lifecycle +There is no separate Execute step. Agent Zero calls `hooks.py` during install, +update, and removal. Those hooks stop background workers and clear plugin module +and bytecode caches so an update does not leave stale worker threads or Python +modules behind. -Each harness run tracks a concrete coding workflow: +The plugin has no additional Python package installation step. -1. `inspect` -2. `plan` -3. `implement` -4. `verify` -5. `repair` -6. `summarize` -7. `complete` +If Agent Zero reports `Need to specify how to reconcile divergent branches`, +the installed plugin checkout contains Git history that no longer fast-forwards +to the repository's `main`. Back up any local plugin changes, then reinstall the +plugin or explicitly return that checkout to the repository's `main`. The +plugin cannot safely reset that checkout from `hooks.py` because Agent Zero's +Git update fails before the new hook code is loaded. -The practical behavior depends on mode: +## Recommended workflow -- `flash` favors the shortest safe path -- `standard` keeps the work thoughtful but single-agent -- `pro` encourages deliberate inspect -> plan -> implement -> verify work -- `ultra` expects task decomposition and supports dispatch/collect loops for parallel work +For most work: -When a task graph exists, the harness tracks: +1. Start a `pro` run with a concrete objective. +2. Inspect the relevant repository and constraints. +3. Move through plan and implementation in the main agent. +4. Run a concrete verification command. +5. Record the verification result and complete the run. -- pending sub-tasks -- dispatched sub-tasks -- completed sub-tasks -- failed sub-tasks +For safely decomposable work: -The dashboard renders both a Mermaid graph and a text fallback view of the graph. +1. Start an `ultra` run. +2. Submit a task graph with explicit dependencies. +3. Dispatch only tasks that can edit without overlap. +4. Collect worker results until the graph is complete. +5. Repair or manually adopt any failed task in the main chat. +6. Run and record a passing integration verification. +7. Complete the run. -## Safety Model +Completion fails closed when the latest verification is not passing. Ultra also +requires every task in its graph to be completed successfully. -Agent Harness adds review checkpoints before risky behavior. +## Safety checkpoints -By default, checkpoints are required for: +The default policy requires approval before: -- dependency installation commands -- destructive shell commands -- edits to protected files or folders +- dependency installs such as `pip`, `uv`, `npm`, `brew`, or `apt` +- destructive filesystem or Git commands +- Git mutations such as commit, push, merge, rebase, branch, tag, or stash +- edits to a configured protected path +- exceeding the automatic distinct-file edit limit -Default protected paths: +Default protected paths are: - `agent.py` - `initialize.py` - `usr/plugins/` -The guardrail matcher treats folder-style entries such as `usr/plugins/` as directory prefixes, so nested files under that path are protected too. - -The plugin also exposes a manual checkpoint tool so the agent can request approval proactively when the action is risky even if it is not caught by a pattern. - -## Memory Curation - -Instead of silently persisting every reusable observation, the harness can queue memory proposals for review. - -Each proposal includes: - -- the candidate rule text -- a reason -- source metadata -- a target scope - -Supported scopes: - -- `project` -- `agent` -- `global` - -Accepted rules are surfaced back into future harness prompts as accepted project rules. - -## Thread Data Layout - -When workspace support is enabled, the plugin prepares DeerFlow-style thread data directories for each chat context. - -If the chat is attached to a project, the data is rooted under that project. - -If the chat is not attached to a project, the data is rooted under the chat storage folder. - -Layout: - -```text -.harness/ - threads/ - / - user-data/ - workspace/ - uploads/ - outputs/ - offloads/ - runs/ -``` - -Meaning: - -- `workspace/`: thread-local scratch space for implementation work -- `uploads/`: files uploaded into the thread -- `outputs/`: generated artifacts for the thread -- `offloads/`: auxiliary markdown offloads -- `runs/`: saved run logs - -Project-backed workspaces also ensure the project's `.gitignore` includes: - -- `.harness/workspace/` -- `.harness/offloads/` -- `.harness/threads/` - -## User Interface - -The plugin includes several user-facing surfaces: - -### Dashboard - -The dashboard shows: +Read-only file access and read-only Git commands do not consume an edit +checkpoint. An approval is tied to the exact tool name and arguments and is +consumed by one execution attempt; changed arguments or retries require a new +approval. -- current run objective, mode, phase, status, and risk level -- tracked tasks -- task graph progress -- pending checkpoints -- pending memory proposals -- recent accepted rules -- the latest verification result - -### Status Strip - -When enabled, the plugin can display a compact harness status strip above the chat input so the current run remains visible without opening the full dashboard. - -### Sidebar Entry - -The plugin also registers sidebar/status entrypoints so the harness can be reached from the existing Agent Zero UI shell. - -## Tooling Inside the Agent Loop +The dashboard can approve or reject a pending checkpoint. While one is pending, +unrelated tool execution remains blocked so an agent cannot route around it. -The plugin exposes three primary tools inside Agent Zero. +## Tools ### `harness_run` -This is the main orchestration tool. - -Supported actions: - -- `start`: begin a run with a mode, objective, and optional constraints -- `status`: summarize the current run -- `phase`: move the run to a new phase -- `plan`: submit a task graph for decomposed work -- `dispatch`: spawn ready sub-tasks in parallel -- `collect`: collect completed sub-task results and update the graph -- `task`: upsert tracked task items -- `verification`: record a verification result -- `failure`: record a bounded failure or repair loop -- `clean`: clear workspace/offloads while preserving outputs and run logs -- `complete`: finish the run after verification and task completion +The run controller supports: + +- `start`: create a run with `mode`, `objective`, and optional `constraints` +- `status`: report the current mode, phase, and state +- `phase`: advance to a valid lifecycle phase +- `task`: track a single-agent work item +- `plan`: Ultra only; submit a non-empty task graph +- `dispatch`: Ultra only; start ready tasks up to the worker limit +- `collect`: Ultra only; harvest worker results +- `adopt`: Ultra only; reconcile work completed in the main chat +- `verification`: record a passed, failed, or unknown check +- `failure`: record a bounded repair failure +- `clean`: clear the per-chat scratch workspace +- `complete`: finish only after the completion gates pass ### `harness_checkpoint` -This tool creates a manual review checkpoint with: - -- a reason -- a proposed action -- optional tool metadata -- risk level - -Use it when the action is risky and should pause for approval even if the guardrail layer has not blocked it automatically. +Creates a manual checkpoint for an exact proposed tool action. Dependency, +destructive, Git-mutation, and protected-path guardrails also create +checkpoints automatically. ### `harness_memory_propose` -This tool submits candidate reusable rules into the memory review queue. - -Use it when the agent learns a durable repo convention that should be reviewed before being persisted. - -If `memory_curation_enabled` is turned off, proposed rules are accepted immediately instead of being queued. +Proposes a durable harness rule for `project`, `agent`, or `global` scope. +Curated mode queues it for review. If curation is disabled, the rule is written +directly to the selected harness configuration scope. -## Runtime Wiring +Harness rules are not mirrored into Agent Zero's separate memory plugin. -The plugin uses Agent Zero lifecycle extensions to keep the harness active throughout a run. +## Per-chat data -Important runtime hooks: +Harness data stays under Agent Zero's chat storage. It does not create a +`.harness` directory in the attached project and does not edit the project's +`.gitignore`. -- `extensions/python/tool_execute_before/_20_harness_guardrails.py` - Blocks risky execution and opens checkpoints. -- `extensions/python/tool_execute_after/_20_harness_tool_events.py` - Tracks tool activity and sub-task outcomes. -- `extensions/python/message_loop_prompts_after/_20_harness_runtime.py` - Injects harness instructions into the active prompt. -- `extensions/python/chat_model_call_after/_20_harness_cost.py` - Tracks token usage and budget information. -- `extensions/python/monologue_start/_20_harness_workspace.py` - Prepares thread-scoped workspace roots before work begins. - -## DeerFlow-Like Core Bridge - -The plugin exposes DeerFlow-style host state through a local facade. - -Core bridge helpers: - -- `helpers/deerflow_core.py` -- `helpers/deerflow_client.py` -- `helpers/workspace.py` - -The bridge currently surfaces: - -- thread path summary -- configured models -- available skills -- memory status -- thread uploads -- thread artifacts - -This is intended to make Agent Zero feel more like DeerFlow at the harness boundary, even though the underlying runtime is still Agent Zero. - -## Plugin APIs - -All plugin API routes live under `/plugins/agent_harness/`. - -| Endpoint | Purpose | -| --- | --- | -| `run` | start, inspect current run state, stop a run, and submit checkpoint decisions | -| `state` | dashboard state, pending checkpoints, memory queue, recent rules, latest verification | -| `memory_queue` | accept or reject queued memory proposals | -| `deerflow_core` | DeerFlow-like thread, model, skill, and memory summary | -| `thread_data` | inspect thread data roots or clean them up | -| `thread_uploads` | list, upload, or delete thread-local uploads | -| `thread_artifacts` | list generated artifacts or download a specific artifact | +```text +/ + .harness/ + threads/ + / + user-data/ + workspace/ + uploads/ + outputs/ +``` -Behavior notes: +- `workspace/` is disposable scratch space. +- `uploads/` contains files uploaded for this chat. +- `outputs/` contains downloadable artifacts. -- `run` is what the dashboard uses for `start`, `checkpoint_decide`, and `stop` -- `state` is the dashboard polling endpoint -- `thread_uploads` accepts `GET` and `POST` and supports `action=upload` and `action=delete` -- `thread_artifacts` supports listing artifacts and downloading a requested file via `GET` -- `thread_data` supports `action=status` and `action=cleanup` +Upload paths and artifact paths are confined to their per-chat directories. +The API rejects traversal and symlink escapes, limits each upload to 25 MiB, +limits a request batch to 100 MiB, and lists at most 1,000 files. ## Settings -The plugin ships a default config in `default_config.yaml` and supports both per-project and per-agent overrides. +Workflow: -| Setting | Default | Meaning | -| --- | --- | --- | -| `ambient_assist_enabled` | `true` | keep lightweight coding guidance active even without an explicit run | -| `default_deep_mode` | `pro` | default mode when starting a deep harness workflow | -| `memory_curation_enabled` | `true` | queue reusable rules for review instead of silently persisting them | -| `show_status_ui` | `true` | display the compact harness status strip | -| `max_auto_edit_files` | `8` | cap on automatic broad edit fan-out before the task should slow down | -| `dependency_install_requires_checkpoint` | `true` | require approval before install commands | -| `destructive_actions_require_checkpoint` | `true` | require approval before destructive commands | -| `protected_paths` | see config | files and directory prefixes that always require approval before editing | -| `mode_policies` | varies by mode | subagent and repair-loop policy per mode | -| `context_pressure_threshold` | `0.7` | threshold used to classify context pressure | -| `context_model_window` | `128000` | assumed context window for pressure calculations | -| `workspace_enabled` | `true` | create DeerFlow-style thread workspace roots | -| `token_budget` | `0` | optional hard budget for usage tracking; `0` means disabled | -| `cost_tracking_enabled` | `true` | track prompt/completion token usage during runs | +- Ambient Assist +- Default Mode +- Ultra Workers, clamped to 1-4 +- Per-mode bounded repair limits -Current default mode policies: +Safety: -- `flash`: `subagent_limit=0`, `repair_limit=0` -- `standard`: `subagent_limit=0`, `repair_limit=1` -- `pro`: `subagent_limit=0`, `repair_limit=1` -- `ultra`: `subagent_limit=3`, `repair_limit=3` +- Dependency Install checkpoints +- Destructive Action checkpoints +- Git Mutation checkpoints +- Automatic Edit Limit +- Protected Paths -## Included DeerFlow Compatibility Assets +Data and status: -The plugin includes local DeerFlow-style assets so it is usable without copying files out of the upstream DeerFlow repository. +- Thread Workspace +- Curated Memory +- Status Chip +- Usage Tracking +- Approximate Output Budget -Included assets: +The plugin supports global, per-project, and per-agent configuration. Runtime +`config.json` is user state and is intentionally not included in the plugin +repository. -- `Install.md` -- `scripts/check_deerflow_harness.py` -- `scripts/import_deerflow_public_skills.py` -- `skills/public/bootstrap` -- `skills/public/find-skills` +## Observability canvas -Use these when you want to: +The compact Harness control beside the chat input opens a right-side canvas, +keeping observability visible beside the conversation without covering it. The +sidebar shortcut opens the same surface. On phone-sized layouts, where Agent +Zero disables its right canvas, the control falls back to the floating +dashboard. -- verify that the plugin has all expected DeerFlow-style assets -- compare local plugin state against a DeerFlow checkout -- import DeerFlow public skills into Agent Zero +The responsive canvas shows: -## Verification +- current objective, mode, phase, state, and risk +- Ultra task-graph progress and worker results +- pending approval checkpoints with approve and reject actions +- pending memory proposals +- latest verification, failures, and approximate token usage -Basic parity and plugin checks: +The full floating dashboard remains available through the canvas undock action +and includes recent accepted rules plus per-chat upload/output management. The +task graph is rendered locally; the plugin does not load Mermaid or another +visualization package from a CDN. -```bash -./.venv/bin/python usr/plugins/agent_harness/scripts/check_deerflow_harness.py -./.venv/bin/pytest -q tests/test_agent_harness_plugin.py tests/test_harness_*.py -``` +## Development -Verify against a local DeerFlow checkout: +The standalone regression suite can run outside Agent Zero with its included +host compatibility stubs: ```bash -./.venv/bin/python usr/plugins/agent_harness/scripts/check_deerflow_harness.py --source /path/to/deer-flow +python -m unittest discover -s tests -v +node --check webui/harness-store.js +python -m compileall -q . ``` -Optional DeerFlow public skill import: +The same tests can exercise a real Agent Zero checkout by setting: ```bash -./.venv/bin/python usr/plugins/agent_harness/scripts/import_deerflow_public_skills.py --source /path/to/deer-flow +A0_TEST_USE_REAL_CORE=1 +A0_TEST_PROJECT_ROOT=/path/to/agent-zero +PYTHONPATH=/path/to/agent-zero ``` -## Recommended Usage Pattern - -For most users, this flow works well: - -1. Enable the plugin for the current project or agent. -2. Start a `pro` run for normal coding work. -3. Switch to `ultra` only when the task is large enough to justify decomposition. -4. Let checkpoints slow you down before risky actions instead of bypassing them. -5. Review memory proposals so durable project rules stay clean and intentional. -6. Treat `outputs/` as the canonical place for thread-local generated artifacts. -7. Run verification before completing the harness run. - -## Troubleshooting - -### The dashboard is empty - -Make sure the plugin is enabled for the current scope and that the current chat context exists. - -### I do not see a run - -No run exists until one is started. The dashboard can still load, but run-specific controls will remain idle until `start` is called. - -### The harness keeps blocking an action - -Check the pending checkpoint queue. The block may be caused by: - -- a dependency installation command -- a destructive command -- a protected path edit - -### Uploads or artifacts are missing - -Remember that uploads and outputs are thread-scoped, not global. A different chat context will have a different thread data root. - -### I want full DeerFlow backend parity - -This plugin gets close at the harness layer, but full parity would require Agent Zero core work beyond the plugin boundary, especially around LangGraph-style execution and a separate gateway/runtime service. - -## File Guide - -If you are extending the plugin itself, these are the most important files: - -- `plugin.yaml` -- `default_config.yaml` -- `api/run.py` -- `api/state.py` -- `api/memory_queue.py` -- `api/deerflow_core.py` -- `api/thread_data.py` -- `api/thread_uploads.py` -- `api/thread_artifacts.py` -- `tools/harness_run.py` -- `tools/harness_checkpoint.py` -- `tools/harness_memory_propose.py` -- `helpers/deerflow_core.py` -- `helpers/deerflow_client.py` -- `helpers/workspace.py` -- `webui/dashboard.html` -- `webui/config.html` -- `webui/harness-store.js` - -## Related Docs +CI runs the standalone suite on Python 3.11, 3.12, and 3.13. -- `Install.md` -- `docs/plans/2026-04-01-deerflow-core-parity-design.md` +## License -If you want the design rationale behind the DeerFlow parity bridge, read the design doc. If you want the operational bootstrap and verification path, read `Install.md`. +MIT. See [LICENSE](LICENSE). diff --git a/api/memory_queue.py b/api/memory_queue.py index b174b15..6a56cd2 100644 --- a/api/memory_queue.py +++ b/api/memory_queue.py @@ -1,5 +1,6 @@ from __future__ import annotations +from helpers import projects from helpers.api import ApiHandler, Request, Response from usr.plugins.agent_harness.helpers import runtime @@ -16,12 +17,22 @@ async def process(self, input: dict, request: Request) -> dict | Response: return {"success": False, "candidate": None, "error": "No active harness run"} if action == "accept": + scope = str(input.get("scope", "project")).strip().lower() + agent = context.get_agent() + project_name = str(input.get("project_name", "")).strip() + agent_profile = str(input.get("agent_profile", "")).strip() + if scope == "project" and not project_name: + project_name = projects.get_context_project_name(context) or "" + if not project_name: + scope = "agent" if agent and agent.config.profile else "global" + if scope == "agent" and not agent_profile and agent: + agent_profile = agent.config.profile or "" candidate = await runtime.accept_memory_candidate( context=context, candidate_id=str(input.get("candidate_id", "")).strip(), - scope=str(input.get("scope", "project")).strip().lower(), # type: ignore[arg-type] - project_name=str(input.get("project_name", "")).strip(), - agent_profile=str(input.get("agent_profile", "")).strip(), + scope=scope, # type: ignore[arg-type] + project_name=project_name, + agent_profile=agent_profile, ) refreshed_run = runtime.get_current_run(context) return { diff --git a/api/run.py b/api/run.py index 8e9b758..65d4d91 100644 --- a/api/run.py +++ b/api/run.py @@ -12,6 +12,14 @@ async def process(self, input: dict, request: Request) -> dict | Response: context = self.use_context(context_id, create_if_not_exists=action == "start") if action == "start": + existing = runtime.get_current_run(context) + if existing: + try: + from usr.plugins.agent_harness.helpers.parallel import kill_all + + kill_all(existing.run_id) + except ImportError: + pass settings = runtime.load_context_settings(context) run = runtime.create_run_record( context_id=context.id, diff --git a/api/thread_artifacts.py b/api/thread_artifacts.py index bd68a3a..c8e2ff9 100644 --- a/api/thread_artifacts.py +++ b/api/thread_artifacts.py @@ -22,7 +22,16 @@ async def process(self, input: dict, request: Request) -> dict | Response: if request.method == "GET": relative_path = str(request.args.get("path", "")).strip() - artifact = client.resolve_thread_artifact(relative_path) + if not relative_path: + return { + "success": True, + "context_id": context.id, + "artifacts": client.list_thread_artifacts(), + } + try: + artifact = client.resolve_thread_artifact(relative_path) + except ValueError as exc: + return Response(str(exc), status=400) if not artifact.exists() or not artifact.is_file(): return Response("Artifact not found", status=404) download = str(request.args.get("download", "1")).strip().lower() not in { diff --git a/api/thread_uploads.py b/api/thread_uploads.py index ffc1052..e98d4dc 100644 --- a/api/thread_uploads.py +++ b/api/thread_uploads.py @@ -4,6 +4,12 @@ from helpers.security import safe_filename from usr.plugins.agent_harness.helpers.deerflow_client import DeerFlowClient +from usr.plugins.agent_harness.helpers.upload_limits import ( + MAX_UPLOAD_BATCH_BYTES, + UploadTooLargeError, + format_byte_limit, + read_upload_bytes, +) class ThreadUploads(ApiHandler): @@ -26,11 +32,17 @@ async def process(self, input: dict, request: Request) -> dict | Response: context = self.use_context(str(context_id).strip(), create_if_not_exists=False) client = DeerFlowClient(context) + if action in {"delete", "upload"} and request.method != "POST": + return Response("Upload mutations require POST.", status=405) + if action == "delete": relative_path = str( request.form.get("path") or input.get("path") or input.get("filename", "") ).strip() - deleted = client.delete_thread_upload(relative_path) + try: + deleted = client.delete_thread_upload(relative_path) + except ValueError as exc: + return Response(str(exc), status=400) return { "success": deleted, "context_id": context.id, @@ -51,6 +63,8 @@ async def process(self, input: dict, request: Request) -> dict | Response: saved: list[str] = [] skipped: list[str] = [] + rejected: list[dict[str, str]] = [] + batch_size = 0 for file in files_to_save: if not file or not file.filename: continue @@ -58,17 +72,42 @@ async def process(self, input: dict, request: Request) -> dict | Response: if not filename: skipped.append(file.filename) continue - client.save_thread_upload(filename, file.read()) + try: + content = read_upload_bytes(file) + except UploadTooLargeError as exc: + rejected.append({"name": file.filename, "reason": str(exc)}) + continue + if batch_size + len(content) > MAX_UPLOAD_BATCH_BYTES: + rejected.append( + { + "name": file.filename, + "reason": ( + "Upload batch exceeds the " + f"{format_byte_limit(MAX_UPLOAD_BATCH_BYTES)} limit." + ), + } + ) + continue + try: + client.save_thread_upload(filename, content) + except ValueError as exc: + rejected.append({"name": file.filename, "reason": str(exc)}) + continue + batch_size += len(content) saved.append(filename) return { - "success": True, + "success": bool(saved), "context_id": context.id, "saved": saved, "skipped": skipped, + "rejected": rejected, "uploads": client.list_thread_uploads(), } + if action != "list": + return Response(f"Unknown upload action: {action}", status=400) + return { "success": True, "context_id": context.id, diff --git a/config.json b/config.json deleted file mode 100644 index a12c7de..0000000 --- a/config.json +++ /dev/null @@ -1 +0,0 @@ -{"protected_paths": ["agent.py", "initialize.py", "usr/plugins/"], "ambient_assist_enabled": true, "memory_curation_enabled": true, "show_status_ui": true, "default_deep_mode": "pro", "mode_policies": {"standard": {"repair_limit": 2, "subagent_limit": 4}, "pro": {"repair_limit": 4, "subagent_limit": 6}, "ultra": {"subagent_limit": 6, "repair_limit": 8}}} \ No newline at end of file diff --git a/default_config.yaml b/default_config.yaml index ac86656..f300399 100644 --- a/default_config.yaml +++ b/default_config.yaml @@ -1,4 +1,4 @@ -config_version: 3 +config_version: 4 ambient_assist_enabled: true default_deep_mode: pro memory_curation_enabled: true @@ -6,6 +6,7 @@ show_status_ui: true max_auto_edit_files: 8 dependency_install_requires_checkpoint: true destructive_actions_require_checkpoint: true +git_mutations_require_checkpoint: true protected_paths: - agent.py - initialize.py @@ -24,8 +25,6 @@ mode_policies: ultra: subagent_limit: 3 repair_limit: 3 -context_pressure_threshold: 0.7 -context_model_window: 128000 workspace_enabled: true token_budget: 0 cost_tracking_enabled: true diff --git a/extensions/python/chat_model_call_after/_20_harness_cost.py b/extensions/python/chat_model_call_after/_20_harness_cost.py index 174f7ad..fb9b577 100644 --- a/extensions/python/chat_model_call_after/_20_harness_cost.py +++ b/extensions/python/chat_model_call_after/_20_harness_cost.py @@ -8,6 +8,22 @@ from usr.plugins.agent_harness.helpers.guardrails import request_checkpoint +def _budget_checkpoint_exists(run, budget: int) -> bool: + return any( + checkpoint.tool_name == "harness_budget" + and checkpoint.tool_args.get("run_id") == run.run_id + and checkpoint.tool_args.get("budget") == budget + for checkpoint in run.checkpoints + ) + + +def _configured_budget(settings: dict) -> int: + try: + return max(0, int(settings.get("token_budget", 0))) + except (TypeError, ValueError): + return 0 + + class HarnessCost(Extension): async def execute(self, response: str = "", **kwargs): if not self.agent: @@ -27,15 +43,29 @@ async def execute(self, response: str = "", **kwargs): if completion_tokens > 0: record_usage(run, prompt_tokens=0, completion_tokens=completion_tokens) - lifecycle.save_current_run(self.agent.context, run) + budget = _configured_budget(agent_settings) + if run.cost: + run.cost.budget_limit = budget + run.cost.budget_remaining = max( + 0, + budget - run.cost.usage.total_tokens, + ) - if check_budget(run, agent_settings): + if ( + check_budget(run, agent_settings) + and not _budget_checkpoint_exists(run, budget) + ): request_checkpoint( run, - reason="Token budget exhausted. Approve to continue or stop the run.", - proposed_action="Continue execution beyond token budget", - tool_name="harness_cost", - tool_args={}, + reason=( + f"Approximate output-token budget of {budget} was reached. " + "Approve once to continue this run without another budget prompt." + ), + proposed_action=( + f"Continue run {run.run_id} beyond its configured token budget" + ), + tool_name="harness_budget", + tool_args={"run_id": run.run_id, "budget": budget}, risk_level="high", ) - lifecycle.save_current_run(self.agent.context, run) + lifecycle.save_current_run(self.agent.context, run) diff --git a/extensions/python/message_loop_prompts_after/_20_harness_runtime.py b/extensions/python/message_loop_prompts_after/_20_harness_runtime.py index 648921d..12a05ee 100644 --- a/extensions/python/message_loop_prompts_after/_20_harness_runtime.py +++ b/extensions/python/message_loop_prompts_after/_20_harness_runtime.py @@ -3,19 +3,32 @@ from agent import LoopData from helpers.extension import Extension -from usr.plugins.agent_harness.helpers import runtime +from usr.plugins.agent_harness.helpers.lifecycle import ( + get_current_run, + save_current_run, +) +from usr.plugins.agent_harness.helpers.models import ( + DEFAULT_RUN_OBJECTIVE, + PARALLEL_WORKER_CONTEXT_KEY, +) +from usr.plugins.agent_harness.helpers.renderer import render_system_prompt +from usr.plugins.agent_harness.helpers.settings import load_agent_settings class HarnessRuntimePrompt(Extension): - async def execute(self, loop_data: LoopData = LoopData(), **kwargs): - if not self.agent: - return - run = runtime.get_current_run(self.agent) - if not run: + async def execute(self, loop_data: LoopData | None = None, **kwargs): + if not self.agent or loop_data is None: return + settings = load_agent_settings(self.agent) + run = get_current_run(self.agent) + # Auto-update objective from first user message if still default - if run.objective == runtime.DEFAULT_RUN_OBJECTIVE and self.agent.last_user_message: + if ( + run + and run.objective == DEFAULT_RUN_OBJECTIVE + and self.agent.last_user_message + ): msg_text = "" if hasattr(self.agent.last_user_message, "message"): msg_text = str(self.agent.last_user_message.message) @@ -27,8 +40,30 @@ async def execute(self, loop_data: LoopData = LoopData(), **kwargs): msg_text = str(content) if msg_text and len(msg_text.strip()) > 10: run.objective = msg_text.strip()[:200] - runtime.save_current_run(self.agent.context, run) + save_current_run(self.agent.context, run) + + prompt = render_system_prompt( + settings=settings, + run=run, + accepted_rules=list(settings.get("accepted_rules", [])), + ) + worker = self.agent.context.get_data( + PARALLEL_WORKER_CONTEXT_KEY, + recursive=False, + ) + if worker and prompt: + prompt = "\n\n".join( + [ + prompt, + "PARALLEL WORKER SAFETY", + "- Work only on the assigned sub-task and avoid unrelated changes.", + "- Do not install dependencies, run destructive commands, edit protected paths, or exceed the edit breadth limit.", + "- If the sub-task needs approval, stop and report the exact blocked action so the main chat can perform it.", + ] + ) - summary = runtime.render_runtime_summary(run) - if summary: - loop_data.extras_persistent["agent_harness_runtime"] = summary + key = "agent_harness_runtime" + if prompt: + loop_data.extras_persistent[key] = prompt + else: + loop_data.extras_persistent.pop(key, None) diff --git a/extensions/python/monologue_start/_20_harness_workspace.py b/extensions/python/monologue_start/_20_harness_workspace.py index f608efb..871693a 100644 --- a/extensions/python/monologue_start/_20_harness_workspace.py +++ b/extensions/python/monologue_start/_20_harness_workspace.py @@ -1,11 +1,11 @@ from __future__ import annotations from helpers.extension import Extension -from helpers import persist_chat, projects +from helpers import persist_chat from usr.plugins.agent_harness.helpers import lifecycle from usr.plugins.agent_harness.helpers import settings as harness_settings -from usr.plugins.agent_harness.helpers.workspace import ensure_workspace, ensure_gitignore +from usr.plugins.agent_harness.helpers.workspace import ensure_workspace class HarnessWorkspace(Extension): @@ -18,11 +18,7 @@ async def execute(self, **kwargs): agent_settings = harness_settings.load_agent_settings(self.agent) if not agent_settings.get("workspace_enabled", True): return - project_name = projects.get_context_project_name(self.agent.context) or "" - project_dir = projects.get_project_folder(project_name) if project_name else "" - base_dir = project_dir or persist_chat.get_chat_folder_path(self.agent.context.id) + base_dir = persist_chat.get_chat_folder_path(self.agent.context.id) paths = ensure_workspace(base_dir, context_id=self.agent.context.id) - if project_dir: - ensure_gitignore(project_dir) run.workspace = paths lifecycle.save_current_run(self.agent.context, run) diff --git a/extensions/python/tool_execute_after/_20_harness_tool_events.py b/extensions/python/tool_execute_after/_20_harness_tool_events.py index f857986..b7f2246 100644 --- a/extensions/python/tool_execute_after/_20_harness_tool_events.py +++ b/extensions/python/tool_execute_after/_20_harness_tool_events.py @@ -26,19 +26,4 @@ async def execute( tool_args=tool_args, tool_response=response.message if response else "", ) - # Task graph: match call_subordinate results to dispatched sub-tasks - if tool_name == "call_subordinate" and run.task_graph: - from usr.plugins.agent_harness.helpers.orchestrator import record_dispatch_result - message = str(tool_args.get("message", "")).strip() - tool_response_str = response.message if response else "" - for task in run.task_graph.sub_tasks: - if task.status == "dispatched" and (task.id in message or task.title in message): - record_dispatch_result(run, task.id, { - "summary": tool_response_str[:500] if tool_response_str else "", - "files": [], - "status": "completed", - }) - break - if run.task_graph.is_complete(): - run.phase = "verify" runtime.save_current_run(self.agent.context, run) diff --git a/extensions/python/tool_execute_before/_20_harness_guardrails.py b/extensions/python/tool_execute_before/_20_harness_guardrails.py index 253bdba..cf23d10 100644 --- a/extensions/python/tool_execute_before/_20_harness_guardrails.py +++ b/extensions/python/tool_execute_before/_20_harness_guardrails.py @@ -16,15 +16,52 @@ async def execute(self, tool_name: str = "", tool_args: dict | None = None, **kw return settings = runtime.load_agent_settings(self.agent) - checkpoint = runtime.assess_tool_guardrail( + assessment = runtime.assess_tool_guardrail_decision( run=run, tool_name=tool_name, tool_args=tool_args or {}, settings=settings, ) + if assessment.approved_checkpoint: + runtime.save_current_run(self.agent.context, run) + return + + if assessment.denied_checkpoint: + runtime.save_current_run(self.agent.context, run) + raise RuntimeError( + "Agent Harness stopped a repeated risky action. " + f"{assessment.denial_reason}" + ) + + checkpoint = assessment.checkpoint if not checkpoint: return + worker = self.agent.context.get_data( + runtime.PARALLEL_WORKER_CONTEXT_KEY, + recursive=False, + ) + if worker: + checkpoint.status = "rejected" + checkpoint.decision_comment = ( + "Parallel workers cannot request or consume user approvals." + ) + checkpoint.decided_at = runtime.now_iso() + runtime.record_failure( + run, + summary=( + f"Parallel worker stopped at an approval boundary: " + f"{checkpoint.proposed_action}" + ), + settings=settings, + ) + runtime.save_current_run(self.agent.context, run) + raise RuntimeError( + "Agent Harness stopped this parallel worker because its assigned " + "sub-task requires user approval. Return the blocked action to the " + "main chat instead of retrying it." + ) + runtime.save_current_run(self.agent.context, run) raise RepairableException( "Agent Harness blocked a risky action before execution. " diff --git a/extensions/webui/chat-input-progress-start/agent-harness-status.html b/extensions/webui/chat-input-progress-start/agent-harness-status.html index 6d0eba1..27af1b0 100644 --- a/extensions/webui/chat-input-progress-start/agent-harness-status.html +++ b/extensions/webui/chat-input-progress-start/agent-harness-status.html @@ -6,12 +6,106 @@ > + + diff --git a/extensions/webui/right-canvas-panels/_20_agent_harness_panel.html b/extensions/webui/right-canvas-panels/_20_agent_harness_panel.html new file mode 100644 index 0000000..f9540e7 --- /dev/null +++ b/extensions/webui/right-canvas-panels/_20_agent_harness_panel.html @@ -0,0 +1,11 @@ +
+ +
diff --git a/extensions/webui/sidebar-quick-actions-main-end/agent-harness-entry.html b/extensions/webui/sidebar-quick-actions-main-end/agent-harness-entry.html index ebadf78..c4ba7e8 100644 --- a/extensions/webui/sidebar-quick-actions-main-end/agent-harness-entry.html +++ b/extensions/webui/sidebar-quick-actions-main-end/agent-harness-entry.html @@ -1,10 +1,15 @@ + +
diff --git a/extensions/webui/surfaces_register/_20_register_agent_harness.js b/extensions/webui/surfaces_register/_20_register_agent_harness.js new file mode 100644 index 0000000..dc1cc39 --- /dev/null +++ b/extensions/webui/surfaces_register/_20_register_agent_harness.js @@ -0,0 +1,9 @@ +export default async function registerAgentHarnessSurface(surfaces) { + surfaces.registerSurface({ + id: "agent-harness", + title: "Harness", + icon: "conversion_path", + order: 35, + modalPath: "/plugins/agent_harness/webui/main.html", + }); +} diff --git a/helpers/context_engine.py b/helpers/context_engine.py deleted file mode 100644 index b940504..0000000 --- a/helpers/context_engine.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from usr.plugins.agent_harness.helpers.models import ( - RunRecord, ContextPressure, OffloadRecord, ContextStatus, now_iso, new_id, -) -from usr.plugins.agent_harness.helpers.workspace import write_offload - - -def assess_pressure_from_tokens( - estimated_tokens: int, settings: dict[str, Any], -) -> ContextPressure: - threshold = float(settings.get("context_pressure_threshold", 0.7)) - window = int(settings.get("context_model_window", 128000)) - ratio = estimated_tokens / window if window > 0 else 0.0 - - status: ContextStatus - if ratio >= 0.9: - status = "critical" - elif ratio >= threshold: - status = "elevated" - else: - status = "normal" - - return ContextPressure( - estimated_tokens=estimated_tokens, - threshold_pct=threshold, - status=status, - last_assessed_at=now_iso(), - ) - - -def should_offload(pressure: ContextPressure) -> bool: - return pressure.status in ("elevated", "critical") - - -def offload_content( - run: RunRecord, - content: str, - content_type: str, - sub_task_id: str = "", -) -> OffloadRecord: - offload_id = new_id("off") - file_path = "" - if run.workspace: - file_path = write_offload(run.workspace, offload_id, content) - - summary = content[:100].replace("\n", " ").strip() - if len(content) > 100: - summary += "..." - - record = OffloadRecord( - id=offload_id, - sub_task_id=sub_task_id, - content_type=content_type, - file_path=file_path, - summary=summary, - created_at=now_iso(), - ) - run.offloads.append(record) - return record - - -def render_offload_summaries(run: RunRecord) -> str: - if not run.offloads: - return "" - lines = ["Offloaded content (read files for full details):"] - for rec in run.offloads: - lines.append(f"- [{rec.content_type}] {rec.summary} -> {rec.file_path}") - return "\n".join(lines) diff --git a/helpers/deerflow_core.py b/helpers/deerflow_core.py index 09fba1f..385eaf9 100644 --- a/helpers/deerflow_core.py +++ b/helpers/deerflow_core.py @@ -3,12 +3,11 @@ from typing import Any from agent import Agent, AgentContext -from helpers import persist_chat, plugins, projects, skills as host_skills +from helpers import persist_chat, plugins, skills as host_skills from usr.plugins.agent_harness.helpers import lifecycle from usr.plugins.agent_harness.helpers.workspace import ( cleanup_thread_data, - ensure_gitignore, ensure_workspace, list_artifacts, list_uploads, @@ -20,12 +19,8 @@ def ensure_context_workspace(context: AgentContext): if run and run.workspace and run.workspace.thread_root and run.workspace.uploads: return run.workspace - project_name = projects.get_context_project_name(context) or "" - project_dir = projects.get_project_folder(project_name) if project_name else "" - base_dir = project_dir or persist_chat.get_chat_folder_path(context.id) + base_dir = persist_chat.get_chat_folder_path(context.id) paths = ensure_workspace(base_dir, context_id=context.id) - if project_dir: - ensure_gitignore(project_dir) if run: run.workspace = paths @@ -43,7 +38,6 @@ def summarize_thread_paths(paths) -> dict[str, Any]: "workspace": paths.workspace, "uploads": paths.uploads, "outputs": paths.outputs, - "runs": paths.runs, "upload_count": len(uploads), "artifact_count": len(artifacts), } diff --git a/helpers/deerflow_sync.py b/helpers/deerflow_sync.py deleted file mode 100644 index d09f388..0000000 --- a/helpers/deerflow_sync.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from helpers.skills_import import ConflictPolicy, ImportResult, import_skills - -DEFAULT_NAMESPACE = "deerflow" - - -def _normalize_path(source_path: str | Path) -> Path: - path = Path(source_path).expanduser() - if not path.is_absolute(): - path = (Path.cwd() / path).resolve() - return path - - -def _looks_like_public_skills_root(path: Path) -> bool: - if not path.is_dir(): - return False - return any((child / "SKILL.md").is_file() for child in path.iterdir() if child.is_dir()) - - -def resolve_public_skills_root(source_path: str | Path) -> Path: - source = _normalize_path(source_path) - candidates = ( - source / "skills" / "public", - source / "public", - source, - ) - - for candidate in candidates: - if _looks_like_public_skills_root(candidate): - return candidate.resolve() - - raise FileNotFoundError( - "Could not find a DeerFlow public skills directory. " - "Expected /skills/public, /public, or a direct public skills path." - ) - - -def list_public_skills(source_path: str | Path) -> list[str]: - public_root = resolve_public_skills_root(source_path) - skills = [ - child.name - for child in public_root.iterdir() - if child.is_dir() and (child / "SKILL.md").is_file() - ] - skills.sort() - return skills - - -def import_public_skills( - source_path: str | Path, - *, - namespace: str = DEFAULT_NAMESPACE, - conflict: ConflictPolicy = "skip", - project_name: str | None = None, - agent_profile: str | None = None, -) -> ImportResult: - public_root = resolve_public_skills_root(source_path) - return import_skills( - str(public_root), - namespace=namespace, - conflict=conflict, - dry_run=False, - project_name=project_name, - agent_profile=agent_profile, - ) - - -def collect_plugin_asset_status(plugin_root: str | Path) -> dict[str, bool]: - root = _normalize_path(plugin_root) - expected = { - "install_doc": root / "Install.md", - "check_script": root / "scripts" / "check_deerflow_harness.py", - "import_script": root / "scripts" / "import_deerflow_public_skills.py", - "bootstrap_skill": root / "skills" / "public" / "bootstrap" / "SKILL.md", - "find_skills_skill": root / "skills" / "public" / "find-skills" / "SKILL.md", - } - return {name: path.is_file() for name, path in expected.items()} diff --git a/helpers/guardrails.py b/helpers/guardrails.py index 602a644..33e2a70 100644 --- a/helpers/guardrails.py +++ b/helpers/guardrails.py @@ -1,5 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass +import hashlib +import json import re from fnmatch import fnmatch from pathlib import Path @@ -14,20 +17,70 @@ _normalize_path, DEPENDENCY_INSTALL_RE, DESTRUCTIVE_COMMAND_RE, + GIT_MUTATION_COMMAND_RE, ) +@dataclass +class GuardrailAssessment: + checkpoint: CheckpointRecord | None = None + approved_checkpoint: CheckpointRecord | None = None + denied_checkpoint: CheckpointRecord | None = None + denial_reason: str = "" + + +def is_file_mutation(tool_name: str, tool_args: dict[str, Any]) -> bool: + normalized_name = str(tool_name or "").split(":", 1)[0].strip() + if normalized_name not in {"text_editor", "text_editor_remote"}: + return False + action = str( + tool_args.get("action") + or tool_args.get("method") + or tool_args.get("command") + or "" + ).strip().lower() + if action: + return action in { + "write", + "patch", + "create", + "str_replace", + "insert", + "replace", + } + return any( + key in tool_args + for key in ( + "content", + "patch_text", + "old_text", + "new_text", + "edits", + ) + ) + + def get_pending_checkpoint(run: RunRecord) -> CheckpointRecord | None: - pending = [c for c in run.checkpoints if c.status == "pending"] - for checkpoint in reversed(pending): - if checkpoint.status == "pending": - return checkpoint - return None + return next( + ( + checkpoint + for checkpoint in reversed(run.checkpoints) + if checkpoint.status == "pending" + ), + None, + ) def _is_protected_path(path: str, settings: dict[str, Any]) -> bool: - normalized = _normalize_path(path) basename = Path(path).name + path_variants = {_normalize_path(path)} + try: + resolved = Path(path).expanduser().resolve() + path_variants.add(resolved.as_posix()) + path_variants.add(resolved.relative_to(Path.cwd().resolve()).as_posix()) + except (OSError, RuntimeError, ValueError): + pass + for pattern in settings.get("protected_paths", []): raw_pattern = str(pattern).strip() if not raw_pattern: @@ -37,12 +90,13 @@ def _is_protected_path(path: str, settings: dict[str, Any]) -> bool: looks_like_directory = raw_pattern.endswith("/") or ( "/" in normalized_pattern and not has_glob and Path(raw_pattern).suffix == "" ) - if looks_like_directory: - directory = normalized_pattern.rstrip("/") - if normalized == directory or normalized.startswith(normalized_pattern): + for normalized in path_variants: + if looks_like_directory: + directory = normalized_pattern.rstrip("/") + if normalized == directory or normalized.startswith(f"{directory}/"): + return True + if fnmatch(normalized, raw_pattern) or fnmatch(basename, raw_pattern): return True - if fnmatch(normalized, raw_pattern) or fnmatch(basename, raw_pattern): - return True return False @@ -72,7 +126,7 @@ def _would_cross_edit_breadth_limit( ) -> bool: if run.allow_broad_edits: return False - if tool_name != "text_editor": + if not is_file_mutation(tool_name, tool_args): return False path = str(tool_args.get("path", "")).strip() if not path: @@ -81,7 +135,11 @@ def _would_cross_edit_breadth_limit( touched_files = {_normalize_path(item) for item in run.touched_files} if normalized in touched_files: return False - return len(touched_files) >= int(settings.get("max_auto_edit_files", 8)) + try: + max_files = int(settings.get("max_auto_edit_files", 8)) + except (TypeError, ValueError): + max_files = 8 + return len(touched_files) >= max(1, max_files) def _set_blocked_state(run: RunRecord, risk_level: RiskLevel) -> None: @@ -95,6 +153,87 @@ def _set_active_state(run: RunRecord) -> None: run.status = "active" +def action_fingerprint(tool_name: str, tool_args: dict[str, Any]) -> str: + normalized_tool_name = str(tool_name or "").strip() + payload = { + "tool_name": normalized_tool_name, + "tool_args": _canonical_tool_args(normalized_tool_name, tool_args or {}), + } + serialized = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + + +def _canonical_tool_args(tool_name: str, tool_args: dict[str, Any]) -> Any: + if tool_name != "code_execution_tool": + return _json_safe(tool_args) + + runtime = str(tool_args.get("runtime", "") or "").strip().lower() + canonical: dict[str, Any] = { + "runtime": runtime, + "session": _safe_int(tool_args.get("session", 0), default=0), + "reset": bool(tool_args.get("reset", False) or runtime == "reset"), + "allow_running": bool(tool_args.get("allow_running", False)), + } + if runtime in {"terminal", "python", "nodejs"}: + canonical["code"] = str(tool_args.get("code", "") or "") + return canonical + + +def _safe_int(value: Any, *, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): _json_safe(item) + for key, item in sorted(value.items(), key=lambda pair: str(pair[0])) + } + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + if value is None or isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +def _checkpoint_action_fingerprint(checkpoint: CheckpointRecord) -> str: + if checkpoint.action_fingerprint: + return checkpoint.action_fingerprint + if checkpoint.tool_name == "harness_checkpoint": + proposed_action = checkpoint.proposed_action.strip() + if proposed_action and ( + DEPENDENCY_INSTALL_RE.search(proposed_action) + or DESTRUCTIVE_COMMAND_RE.search(proposed_action) + or GIT_MUTATION_COMMAND_RE.search(proposed_action) + ): + return action_fingerprint( + "code_execution_tool", + {"runtime": "terminal", "code": proposed_action}, + ) + return action_fingerprint(checkpoint.tool_name, checkpoint.tool_args) + + +def _latest_matching_checkpoint( + run: RunRecord, + *, + tool_name: str, + tool_args: dict[str, Any], +) -> CheckpointRecord | None: + fingerprint = action_fingerprint(tool_name, tool_args) + for checkpoint in reversed(run.checkpoints): + if _checkpoint_action_fingerprint(checkpoint) == fingerprint: + return checkpoint + return None + + def request_checkpoint( run: RunRecord, *, @@ -110,6 +249,7 @@ def request_checkpoint( proposed_action=proposed_action, tool_name=tool_name, tool_args=tool_args, + action_fingerprint=action_fingerprint(tool_name, tool_args), risk_level=risk_level, created_at=now_iso(), ) @@ -118,32 +258,73 @@ def request_checkpoint( return checkpoint -def assess_tool_guardrail( +def assess_tool_guardrail_decision( *, run: RunRecord, tool_name: str, tool_args: dict[str, Any], settings: dict[str, Any], -) -> CheckpointRecord | None: - if tool_name in {"harness_run", "harness_checkpoint", "harness_memory_propose"}: - return None +) -> GuardrailAssessment: + if tool_name in { + "harness_run", + "harness_checkpoint", + "harness_memory_propose", + "response", + }: + return GuardrailAssessment() pending = get_pending_checkpoint(run) if pending: - return pending + return GuardrailAssessment( + denied_checkpoint=pending, + denial_reason=( + "A checkpoint is still pending. No additional tool execution is allowed " + "until the user approves or rejects it." + ), + ) + + matching = _latest_matching_checkpoint( + run, + tool_name=tool_name, + tool_args=tool_args, + ) + if matching and matching.status == "approved": + if matching.consumed_at: + _set_blocked_state(run, matching.risk_level) + return GuardrailAssessment( + denied_checkpoint=matching, + denial_reason=( + "The matching checkpoint approval was already consumed by one execution " + "attempt. Request a new checkpoint instead of retrying the action." + ), + ) + matching.consumed_at = now_iso() + _set_active_state(run) + return GuardrailAssessment(approved_checkpoint=matching) + if matching and matching.status == "rejected": + _set_blocked_state(run, matching.risk_level) + return GuardrailAssessment( + denied_checkpoint=matching, + denial_reason=( + "The user rejected the matching checkpoint. Change the proposed action or " + "request a new explicit checkpoint; do not retry it unchanged." + ), + ) if settings.get("dependency_install_requires_checkpoint", True) and _command_matches( tool_name=tool_name, tool_args=tool_args, pattern=DEPENDENCY_INSTALL_RE, ): - return request_checkpoint( - run, - reason="Checkpoint required for dependency install in deep harness mode.", - proposed_action=_terminal_command(tool_name, tool_args).strip(), - tool_name=tool_name, - tool_args=tool_args, - risk_level="high", + return GuardrailAssessment( + checkpoint=request_checkpoint( + run, + reason="Checkpoint required for dependency install in deep harness mode.", + proposed_action=_terminal_command(tool_name, tool_args).strip(), + tool_name=tool_name, + tool_args=tool_args, + risk_level="high", + ), ) if settings.get("destructive_actions_require_checkpoint", True) and _command_matches( @@ -151,37 +332,74 @@ def assess_tool_guardrail( tool_args=tool_args, pattern=DESTRUCTIVE_COMMAND_RE, ): - return request_checkpoint( - run, - reason="Checkpoint required for destructive filesystem or git action.", - proposed_action=_terminal_command(tool_name, tool_args).strip(), - tool_name=tool_name, - tool_args=tool_args, - risk_level="critical", + return GuardrailAssessment( + checkpoint=request_checkpoint( + run, + reason="Checkpoint required for destructive filesystem or git action.", + proposed_action=_terminal_command(tool_name, tool_args).strip(), + tool_name=tool_name, + tool_args=tool_args, + risk_level="critical", + ), + ) + + if settings.get("git_mutations_require_checkpoint", True) and _command_matches( + tool_name=tool_name, + tool_args=tool_args, + pattern=GIT_MUTATION_COMMAND_RE, + ): + return GuardrailAssessment( + checkpoint=request_checkpoint( + run, + reason="Checkpoint required before a repository-changing Git command.", + proposed_action=_terminal_command(tool_name, tool_args).strip(), + tool_name=tool_name, + tool_args=tool_args, + risk_level="high", + ), ) path = str(tool_args.get("path", "")).strip() - if path and _is_protected_path(path, settings): - return request_checkpoint( - run, - reason=f"Checkpoint required for protected path edit: {Path(path).name}.", - proposed_action=path, - tool_name=tool_name, - tool_args=tool_args, - risk_level="high", + if path and is_file_mutation(tool_name, tool_args) and _is_protected_path(path, settings): + return GuardrailAssessment( + checkpoint=request_checkpoint( + run, + reason=f"Checkpoint required for protected path edit: {Path(path).name}.", + proposed_action=path, + tool_name=tool_name, + tool_args=tool_args, + risk_level="high", + ), ) if _would_cross_edit_breadth_limit(run, tool_name, tool_args, settings): - return request_checkpoint( - run, - reason="Checkpoint required before exceeding the automatic edit breadth limit.", - proposed_action=path, - tool_name=tool_name, - tool_args=tool_args, - risk_level="high", + return GuardrailAssessment( + checkpoint=request_checkpoint( + run, + reason="Checkpoint required before exceeding the automatic edit breadth limit.", + proposed_action=path, + tool_name=tool_name, + tool_args=tool_args, + risk_level="high", + ), ) - return None + return GuardrailAssessment() + + +def assess_tool_guardrail( + *, + run: RunRecord, + tool_name: str, + tool_args: dict[str, Any], + settings: dict[str, Any], +) -> CheckpointRecord | None: + return assess_tool_guardrail_decision( + run=run, + tool_name=tool_name, + tool_args=tool_args, + settings=settings, + ).checkpoint def decide_checkpoint( @@ -191,9 +409,15 @@ def decide_checkpoint( decision: Literal["approved", "rejected"], comment: str = "", ) -> CheckpointRecord: + if decision not in {"approved", "rejected"}: + raise ValueError("Checkpoint decision must be 'approved' or 'rejected'.") for checkpoint in run.checkpoints: if checkpoint.id != checkpoint_id: continue + if checkpoint.status != "pending": + raise ValueError( + f"Checkpoint '{checkpoint_id}' was already {checkpoint.status}." + ) checkpoint.status = decision checkpoint.decision_comment = comment checkpoint.decided_at = now_iso() diff --git a/helpers/lifecycle.py b/helpers/lifecycle.py index a4d0e75..8f0b326 100644 --- a/helpers/lifecycle.py +++ b/helpers/lifecycle.py @@ -28,11 +28,24 @@ ) from usr.plugins.agent_harness.helpers.settings import get_mode_policy, get_default_mode -# --- Structured regex for pytest output parsing (BUG FIX #2) --- +# --- Structured test-output parsing --- PYTEST_FAILED_RE = re.compile(r"(\d+)\s+failed", re.IGNORECASE) PYTEST_PASSED_RE = re.compile(r"(\d+)\s+passed", re.IGNORECASE) PYTEST_ERROR_RE = re.compile(r"(\d+)\s+error", re.IGNORECASE) +GENERIC_FAILED_RE = re.compile( + r"(^|\n)\s*(FAILED|FAIL)(\s|$)|" + r"test result:\s*FAILED|" + r"Tests:\s*(?:.*,\s*)?[1-9]\d*\s+failed", + re.IGNORECASE, +) +GENERIC_PASSED_RE = re.compile( + r"(^|\n)\s*OK\s*(\n|$)|" + r"test result:\s*ok\.|" + r"Tests:\s*(?:.*,\s*)?[1-9]\d*\s+passed|" + r"(^|\n)ok\s+\S+", + re.IGNORECASE, +) def parse_verification_status(output: str) -> VerificationStatus: @@ -42,9 +55,13 @@ def parse_verification_status(output: str) -> VerificationStatus: return "failed" if error_match and int(error_match.group(1)) > 0: return "failed" + if GENERIC_FAILED_RE.search(output): + return "failed" passed_match = PYTEST_PASSED_RE.search(output) if passed_match and int(passed_match.group(1)) > 0: return "passed" + if GENERIC_PASSED_RE.search(output): + return "passed" return "unknown" @@ -188,10 +205,14 @@ def clear_current_run(context: AgentContext) -> None: def get_pending_checkpoint(run: RunRecord) -> CheckpointRecord | None: - for checkpoint in reversed(pending_checkpoints(run)): - if checkpoint.status == "pending": - return checkpoint - return None + return next( + ( + checkpoint + for checkpoint in reversed(run.checkpoints) + if checkpoint.status == "pending" + ), + None, + ) # --- Run control --- @@ -236,7 +257,7 @@ def upsert_task(run: RunRecord, title: str, status: str = "active", details: str return task -# --- Verification recording (BUG FIX #5: passed -> summarize, not verify) --- +# --- Verification recording --- def record_verification( run: RunRecord, @@ -284,7 +305,7 @@ def record_failure( return record -# --- Tool activity recording (BUG FIX #2: structured regex for verification) --- +# --- Tool activity recording --- def record_tool_activity( *, @@ -294,7 +315,11 @@ def record_tool_activity( tool_response: str = "", ) -> None: run.last_tool_name = tool_name - if tool_name == "text_editor": + if str(tool_name).split(":", 1)[0] in {"text_editor", "text_editor_remote"}: + from usr.plugins.agent_harness.helpers.guardrails import is_file_mutation + + if not is_file_mutation(tool_name, tool_args): + return path = str(tool_args.get("path", "")).strip() if path: normalized = _normalize_path(path) @@ -304,11 +329,6 @@ def record_tool_activity( run.phase = "implement" return - if tool_name == "call_subordinate": - title = str(tool_args.get("message", "")).strip() or "Parallel subtask" - upsert_task(run, title=title[:120], status="completed") - return - if tool_name == "code_execution_tool": runtime = str(tool_args.get("runtime", "")).lower() command = str(tool_args.get("code", "")).strip() @@ -330,16 +350,32 @@ def record_tool_activity( # --- Run completion --- +def completion_blocker(run: RunRecord) -> str: + if run.status == "blocked": + return "The run is blocked by an unresolved checkpoint." + if run.task_graph and not run.task_graph.is_successful(): + unfinished = [ + task + for task in run.task_graph.sub_tasks + if task.status != "completed" + ] + names = ", ".join(task.title for task in unfinished[:5]) + return ( + f"{len(unfinished)} task(s) are unfinished: {names}. " + "Dispatch pending tasks or repair and adopt failed tasks first." + ) + latest = latest_verification_record(run) + if not latest or latest.status != "passed": + return ( + "A passing verification record is required. Run an appropriate check, " + "then record its concrete result." + ) + return "" + + def complete_run(run: RunRecord) -> RunRecord: if run.status == "blocked": return run - # Auto-mark any remaining pending/dispatched tasks as skipped - if run.task_graph: - for task in run.task_graph.sub_tasks: - if task.status in ("pending", "dispatched"): - task.status = "completed" - task.result_summary = "Skipped — agent completed work directly" - task.completed_at = now_iso() run.phase = "complete" run.status = "completed" run.completed_at = now_iso() diff --git a/helpers/memory.py b/helpers/memory.py index 462e46e..39bdbec 100644 --- a/helpers/memory.py +++ b/helpers/memory.py @@ -1,8 +1,7 @@ from __future__ import annotations -import inspect -from typing import Any -from agent import Agent, AgentContext -from helpers import plugins + +from agent import AgentContext + from usr.plugins.agent_harness.helpers.models import ( MemoryCandidate, MemoryScope, RunRecord, PLUGIN_NAME, now_iso, new_id, ) @@ -43,28 +42,6 @@ def find_memory_candidate(run: RunRecord, candidate_id: str) -> MemoryCandidate: raise ValueError(f"Memory candidate '{candidate_id}' not found") -async def maybe_mirror_rule_to_memory( - *, - agent: Agent | None, - candidate: MemoryCandidate, -) -> None: - if not agent: - return - if "_memory" not in plugins.get_enabled_plugins(agent): - return - from plugins._memory.helpers.memory import Memory - - memory = await Memory.get(agent) - await memory.insert_text( - text=f"Harness rule: {candidate.rule_text}\nReason: {candidate.reason}", - metadata={ - "area": Memory.Area.MAIN.value, - "source": PLUGIN_NAME, - "scope": candidate.scope, - }, - ) - - async def accept_memory_candidate( *, context: AgentContext, @@ -73,6 +50,8 @@ async def accept_memory_candidate( project_name: str = "", agent_profile: str = "", ) -> MemoryCandidate: + if scope not in {"project", "agent", "global"}: + raise ValueError("Memory scope must be project, agent, or global") run = get_current_run(context) if not run: raise ValueError("No active harness run found") @@ -108,12 +87,6 @@ async def accept_memory_candidate( agent_profile=agent_profile, ) save_current_run(context, run) - mirror_result = maybe_mirror_rule_to_memory( - agent=context.get_agent(), - candidate=candidate, - ) - if inspect.isawaitable(mirror_result): - await mirror_result return candidate diff --git a/helpers/models.py b/helpers/models.py index 7a382e7..4bd3a1f 100644 --- a/helpers/models.py +++ b/helpers/models.py @@ -13,6 +13,7 @@ PLUGIN_NAME = "agent_harness" RUN_CONTEXT_KEY = "agent_harness.current_run" OUTPUT_CONTEXT_KEY = "agent_harness" +PARALLEL_WORKER_CONTEXT_KEY = "agent_harness.parallel_worker" HarnessMode = Literal["flash", "standard", "pro", "ultra"] HarnessPhase = Literal[ @@ -34,16 +35,36 @@ VerificationStatus = Literal["passed", "failed", "unknown"] DEPENDENCY_INSTALL_RE = re.compile( - r"(^|\s)(pip|pip3|uv\s+pip|npm|pnpm|yarn|poetry|apt|apt-get|brew)\s+" - r"(install|add)\b", + r"\b(" + r"(?:python(?:3(?:\.\d+)?)?\s+-m\s+)?pip(?:3)?\s+(?:-[^\s]+\s+)*install|" + r"uv\s+(?:pip\s+install|add)|" + r"(?:npm|pnpm|yarn|poetry)\s+(?:install|add)|" + r"(?:apt|apt-get|brew|apk|dnf|yum|conda|mamba)\s+(?:[^\n;&|]*\s)?(?:install|add)" + r")\b", re.IGNORECASE, ) DESTRUCTIVE_COMMAND_RE = re.compile( - r"(rm\s+-[^\n]*\b[rRfF]+\b|git\s+reset\s+--hard|git\s+checkout\s+--|del\s+/f)", + r"(" + r"rm\s+(?:-[^\n;&|]*[rR][^\n;&|]*|--recursive)\b|" + r"git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-[^\s]*[fdx]|" + r"del\s+/f" + r")", + re.IGNORECASE, +) +GIT_MUTATION_COMMAND_RE = re.compile( + r"\bgit\s+(?:-[Cc]\s+\S+\s+)*(" + r"add|commit|push|merge|rebase|cherry-pick|revert|reset|checkout|switch|" + r"clean|tag|stash|branch\s+-[dDmM]" + r")\b", re.IGNORECASE, ) VERIFICATION_COMMAND_RE = re.compile( - r"\b(pytest|npm\s+test|pnpm\s+test|yarn\s+test|uv\s+run\s+pytest)\b", + r"\b(" + r"pytest|uv\s+run\s+pytest|python(?:3)?\s+-m\s+unittest|" + r"npm\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|yarn\s+test|bun\s+test|" + r"cargo\s+test|go\s+test|dotnet\s+test|mvn\s+test|gradle\s+test|" + r"xcodebuild\b[^\n]*\btest" + r")\b", re.IGNORECASE, ) DEFAULT_RUN_OBJECTIVE = "Active coding task" @@ -73,8 +94,10 @@ class CheckpointRecord(BaseModel): status: CheckpointStatus = "pending" decision_comment: str = "" sub_task_id: str = "" + action_fingerprint: str = "" created_at: str decided_at: str = "" + consumed_at: str = "" class VerificationRecord(BaseModel): @@ -138,6 +161,11 @@ def ready_tasks(self) -> list[SubTask]: def is_complete(self) -> bool: return all(t.status in ("completed", "failed") for t in self.sub_tasks) + def is_successful(self) -> bool: + return bool(self.sub_tasks) and all( + t.status == "completed" for t in self.sub_tasks + ) + def has_cycle(self) -> bool: adj: dict[str, list[str]] = {t.id: list(t.depends_on) for t in self.sub_tasks} visited: set[str] = set() @@ -157,25 +185,6 @@ def dfs(node: str) -> bool: return any(dfs(t.id) for t in self.sub_tasks if t.id not in visited) -ContextStatus = Literal["normal", "elevated", "critical"] - - -class ContextPressure(BaseModel): - estimated_tokens: int - threshold_pct: float - status: ContextStatus - last_assessed_at: str - - -class OffloadRecord(BaseModel): - id: str - sub_task_id: str = "" - content_type: str - file_path: str - summary: str - created_at: str - - class TokenUsage(BaseModel): prompt_tokens: int = 0 completion_tokens: int = 0 @@ -196,8 +205,6 @@ class WorkspacePaths(BaseModel): workspace: str outputs: str uploads: str = "" - offloads: str - runs: str thread_root: str = "" user_data: str = "" @@ -220,7 +227,6 @@ class RunRecord(BaseModel): allow_broad_edits: bool = False last_tool_name: str = "" task_graph: TaskGraph | None = None - offloads: list[OffloadRecord] = Field(default_factory=list) cost: CostRecord | None = None workspace: WorkspacePaths | None = None created_at: str diff --git a/helpers/orchestrator.py b/helpers/orchestrator.py index 7b44acb..193ccb4 100644 --- a/helpers/orchestrator.py +++ b/helpers/orchestrator.py @@ -6,7 +6,6 @@ RunRecord, SubTask, ) from usr.plugins.agent_harness.helpers.settings import get_mode_policy -from usr.plugins.agent_harness.helpers.planner import mark_sub_task_completed, mark_sub_task_failed def build_scoped_context(sub_task: SubTask, run: RunRecord) -> str: @@ -27,19 +26,6 @@ def build_scoped_context(sub_task: SubTask, run: RunRecord) -> str: return "\n".join(lines) -def can_dispatch(run: RunRecord, settings: dict[str, Any]) -> bool: - if not run.task_graph: - return False - policy = get_mode_policy(settings, run.mode) - limit = policy["subagent_limit"] - if limit <= 0: - return False - dispatched_count = sum( - 1 for t in run.task_graph.sub_tasks if t.status == "dispatched" - ) - return dispatched_count < limit - - def dispatch_ready_tasks( run: RunRecord, settings: dict[str, Any], ) -> list[SubTask]: @@ -56,30 +42,3 @@ def dispatch_ready_tasks( available_slots = max(0, limit - dispatched_count) ready = run.task_graph.ready_tasks() return ready[:available_slots] - - -def record_dispatch_result( - run: RunRecord, sub_task_id: str, result: dict[str, Any], -) -> SubTask: - status = str(result.get("status", "completed")).strip().lower() - if status == "failed": - return mark_sub_task_failed( - run, sub_task_id, error=str(result.get("error", "")) - ) - return mark_sub_task_completed( - run, sub_task_id, - summary=str(result.get("summary", "")), - files=list(result.get("files", [])), - ) - - -def synthesize_results(run: RunRecord) -> str: - if not run.task_graph: - return "" - lines = [f"# Results for: {run.task_graph.objective}", ""] - for task in run.task_graph.sub_tasks: - if task.status == "completed" and task.result_summary: - lines.append(f"## {task.title}") - lines.append(task.result_summary) - lines.append("") - return "\n".join(lines) diff --git a/helpers/parallel.py b/helpers/parallel.py index 5a80ce3..691eb7d 100644 --- a/helpers/parallel.py +++ b/helpers/parallel.py @@ -10,9 +10,16 @@ from initialize import initialize_agent from usr.plugins.agent_harness.helpers.models import ( - RunRecord, SubTask, now_iso, + PARALLEL_WORKER_CONTEXT_KEY, + RunRecord, + SubTask, + now_iso, ) from usr.plugins.agent_harness.helpers.orchestrator import build_scoped_context +from usr.plugins.agent_harness.helpers.lifecycle import ( + create_run_record, + save_current_run, +) # Module-level registry of active background sub-agents. # DeferredTask objects are not serializable, so they live here instead of on RunRecord. @@ -22,7 +29,9 @@ Agent.DATA_NAME_SUPERIOR, Agent.DATA_NAME_SUBORDINATE, "agent_harness.current_run", + PARALLEL_WORKER_CONTEXT_KEY, } +_RESULT_SUMMARY_LIMIT = 4_000 @dataclass @@ -50,11 +59,22 @@ def _clone_parent_context_data(parent_context: "AgentContext | None") -> dict[st return inherited or None +def _registry_key(run_id: str, sub_task_id: str) -> str: + return f"{run_id}:{sub_task_id}" + + +def _dispose_background(bg: BackgroundSubAgent) -> None: + try: + bg.deferred.kill(terminate_thread=True) + finally: + AgentContext.remove(bg.context.id) + + def registered_task_ids(run_id: str) -> set[str]: with _lock: return { - task_id - for task_id, bg in _active_tasks.items() + bg.sub_task_id + for bg in _active_tasks.values() if bg.run_id == run_id } @@ -84,56 +104,94 @@ def spawn_parallel( ) -> list[str]: """Spawn background agents for each sub-task. Returns list of spawned IDs. - parent_context: if provided, all context data (model config, project settings, - plugin state) is copied to each child so sub-agents use the same LLM and - configuration as the parent. Each child still gets its own isolated history. + Parent configuration and safe context data are copied into each child so the + worker uses the selected profile and project settings while keeping isolated + history and harness state. """ + prepared: list[BackgroundSubAgent] = [] spawned_ids: list[str] = [] - for sub_task in sub_tasks: - scoped_msg = build_scoped_context(sub_task, run) - - # Create an isolated background context and agent - config = initialize_agent() - - # Copy ALL parent context data so the child inherits model config, - # project settings, plugin state, etc. This ensures the sub-agent - # uses the same LLM provider the user selected — not the system default. - inherited_data = _clone_parent_context_data(parent_context) - - ctx = AgentContext( - config=config, - type=AgentContextType.BACKGROUND, - set_current=False, - data=inherited_data, - ) - - agent = ctx.agent0 - - # Seed the agent with the scoped task context - agent.hist_add_user_message( - UserMessage(message=scoped_msg, attachments=[]) - ) - - # Spawn the monologue in a background thread - thread_name = f"harness-{run.run_id}-{sub_task.id}" - deferred = DeferredTask(thread_name=thread_name) - deferred.start_task(agent.monologue) - - bg = BackgroundSubAgent( - sub_task_id=sub_task.id, - run_id=run.run_id, - context=ctx, - agent=agent, - deferred=deferred, - ) - + inherited_data = _clone_parent_context_data(parent_context) + + try: + for sub_task in sub_tasks: + scoped_msg = build_scoped_context(sub_task, run) + config = ( + deepcopy(parent_context.config) + if parent_context is not None + else initialize_agent() + ) + ctx = AgentContext( + config=config, + type=AgentContextType.BACKGROUND, + set_current=False, + data=deepcopy(inherited_data) if inherited_data else None, + ) + child_run = create_run_record( + context_id=ctx.id, + mode="flash", + objective=f"{sub_task.title}: {sub_task.description}".strip(": "), + constraints=[ + "Stay within the assigned sub-task.", + "Stop if an action requires user approval.", + ], + settings=settings, + allow_broad_edits=run.allow_broad_edits, + ) + child_run.phase = "implement" + save_current_run(ctx, child_run) + ctx.set_data( + PARALLEL_WORKER_CONTEXT_KEY, + { + "parent_run_id": run.run_id, + "parent_context_id": run.context_id, + "sub_task_id": sub_task.id, + "role": sub_task.role, + }, + recursive=False, + ) + + agent = ctx.agent0 + agent.hist_add_user_message( + UserMessage(message=scoped_msg, attachments=[]) + ) + prepared.append( + BackgroundSubAgent( + sub_task_id=sub_task.id, + run_id=run.run_id, + context=ctx, + agent=agent, + deferred=DeferredTask( + thread_name=f"harness-{run.run_id}-{sub_task.id}" + ), + ) + ) + + for bg in prepared: + bg.deferred.start_task(bg.agent.monologue) + with _lock: + _active_tasks[ + _registry_key(bg.run_id, bg.sub_task_id) + ] = bg + sub_task = next( + task for task in sub_tasks if task.id == bg.sub_task_id + ) + sub_task.status = "dispatched" + sub_task.dispatched_at = now_iso() + spawned_ids.append(sub_task.id) + except Exception: with _lock: - _active_tasks[sub_task.id] = bg - - # Mark the sub-task as dispatched - sub_task.status = "dispatched" - sub_task.dispatched_at = now_iso() - spawned_ids.append(sub_task.id) + for bg in prepared: + _active_tasks.pop( + _registry_key(bg.run_id, bg.sub_task_id), + None, + ) + for bg in prepared: + _dispose_background(bg) + for sub_task in sub_tasks: + if sub_task.id in spawned_ids: + sub_task.status = "pending" + sub_task.dispatched_at = "" + raise return spawned_ids @@ -144,17 +202,16 @@ def poll_status(run_id: str) -> dict[str, str]: """ results: dict[str, str] = {} with _lock: - for task_id, bg in list(_active_tasks.items()): - if bg.run_id != run_id: - continue - if not bg.deferred.is_ready(): - results[task_id] = "running" - else: - try: - bg.deferred.result_sync(timeout=0) - results[task_id] = "completed" - except Exception: - results[task_id] = "failed" + tasks = [bg for bg in _active_tasks.values() if bg.run_id == run_id] + for bg in tasks: + if not bg.deferred.is_ready(): + results[bg.sub_task_id] = "running" + else: + try: + bg.deferred.result_sync(timeout=0) + results[bg.sub_task_id] = "completed" + except Exception: + results[bg.sub_task_id] = "failed" return results @@ -164,45 +221,54 @@ def collect_completed(run: RunRecord) -> list[tuple[str, str | None, str | None] Removes completed/failed tasks from the registry. """ collected: list[tuple[str, str | None, str | None]] = [] - to_remove: list[str] = [] - with _lock: - for task_id, bg in list(_active_tasks.items()): - if bg.run_id != run.run_id: - continue - if not bg.deferred.is_ready(): - continue - - try: - result = bg.deferred.result_sync(timeout=0) - summary = str(result)[:500] if result else "" - collected.append((task_id, summary, None)) - except Exception as exc: - collected.append((task_id, None, str(exc))) - to_remove.append(task_id) + ready = [ + bg + for bg in _active_tasks.values() + if bg.run_id == run.run_id and bg.deferred.is_ready() + ] - for task_id in to_remove: - _active_tasks.pop(task_id, None) + for bg in ready: + try: + result = bg.deferred.result_sync(timeout=0) + summary = str(result)[:_RESULT_SUMMARY_LIMIT] if result else "" + collected.append((bg.sub_task_id, summary, None)) + except Exception as exc: + collected.append((bg.sub_task_id, None, str(exc))) + with _lock: + _active_tasks.pop( + _registry_key(bg.run_id, bg.sub_task_id), + None, + ) + _dispose_background(bg) return collected def kill_all(run_id: str) -> int: """Kill all background tasks for a run. Returns number killed.""" - killed = 0 with _lock: - to_remove = [ - task_id for task_id, bg in _active_tasks.items() - if bg.run_id == run_id + selected = [ + bg for bg in _active_tasks.values() if bg.run_id == run_id ] - for task_id in to_remove: - bg = _active_tasks.pop(task_id) - try: - bg.deferred.kill() - except Exception: - pass - killed += 1 - return killed + for bg in selected: + _active_tasks.pop( + _registry_key(bg.run_id, bg.sub_task_id), + None, + ) + for bg in selected: + _dispose_background(bg) + return len(selected) + + +def kill_all_runs() -> int: + """Kill every plugin-owned background worker.""" + with _lock: + selected = list(_active_tasks.values()) + _active_tasks.clear() + for bg in selected: + _dispose_background(bg) + return len(selected) def active_count(run_id: str) -> int: diff --git a/helpers/renderer.py b/helpers/renderer.py index b237e9b..9d852b8 100644 --- a/helpers/renderer.py +++ b/helpers/renderer.py @@ -44,13 +44,12 @@ def render_system_prompt( if run.mode == "ultra": prompt.extend([ "ULTRA WORKFLOW (plan + subagents):", - "- For simple single-file tasks: implement directly, verify, complete.", - "- For multi-file tasks (2+ files to create or modify): you MUST plan first.", - ' Use harness_run action="plan" to decompose into sub-tasks BEFORE writing any code.', + "- You MUST create a task graph before implementation, even for a single planned sub-task.", + ' Use harness_run action="plan" to decompose the work BEFORE writing code.', ' Then use action="dispatch" to spawn parallel sub-agents and action="collect" to harvest results.', f"- Up to {policy['subagent_limit']} parallel sub-agents available. USE THEM for independent work.", f"- {repair_limit} repair loops max before surfacing the blocker.", - "- Use harness_checkpoint before: pip/npm install, git push, rm -rf, or editing protected files.", + "- Use harness_checkpoint before dependency installs, repository-changing Git commands, destructive actions, or protected-file edits.", ]) elif run.mode == "pro": prompt.extend([ @@ -64,7 +63,7 @@ def render_system_prompt( "- Phase 4 VERIFY: Run tests. Record results with harness_run action=\"verification\".", "- Phase 5 COMPLETE: Mark done with harness_run action=\"complete\".", f"- {repair_limit} repair loops max before surfacing the blocker.", - "- MANDATORY checkpoints before: dependency installs, destructive commands, protected file edits, git push.", + "- MANDATORY checkpoints before dependency installs, repository-changing Git commands, destructive actions, or protected-file edits.", "- Use harness_checkpoint proactively. Do NOT skip checkpoints.", ]) elif run.mode == "standard": @@ -104,25 +103,39 @@ def render_system_prompt( if rules_text: prompt.extend(["Accepted rules:", rules_text]) - # Phase-aware task graph sections + # Phase-aware workflow sections if run.phase in ("inspect", "plan") and not run.task_graph: if run.phase == "inspect": - prompt.extend([ - "INSPECT PHASE — READ BEFORE ACTING", - "Examine the repo structure, read relevant files, and understand the codebase.", - 'When ready, use harness_run action="phase" phase="plan" to move to planning.', - "Do NOT start writing code yet.", - ]) + next_phase = "plan" if run.mode == "ultra" else "implement" + prompt.extend( + [ + "INSPECT PHASE — READ BEFORE ACTING", + "Examine the repo structure, read relevant files, and understand the codebase.", + f'When ready, use harness_run action="phase" phase="{next_phase}".', + "Do NOT start writing code yet.", + ] + ) + elif run.mode == "ultra": + prompt.extend( + [ + "PLANNING PHASE — DECOMPOSE INDEPENDENT WORK", + "Decompose the objective into sub-tasks before writing code.", + "Only parallelize tasks that can safely share the same workspace without overlapping edits.", + "Available roles: research, code, verify, synthesize.", + "Reference dependencies by zero-based index. Example: depends_on: [0] depends on the first task.", + 'Submit the plan with harness_run action="plan" sub_tasks=[...].', + "Do NOT use code_execution_tool or text_editor until the plan is submitted.", + ] + ) else: - prompt.extend([ - "PLANNING PHASE — REQUIRED BEFORE IMPLEMENTING", - "You MUST decompose the objective into sub-tasks before writing any code.", - "Each sub-task should be independently executable by a parallel sub-agent.", - "Available roles: research (read docs/code), code (implement), verify (test), synthesize (combine).", - "Reference dependencies by index. Example: depends_on: [0] means depends on the first task.", - 'Submit your plan: harness_run action="plan" sub_tasks=[...]', - "Do NOT use code_execution_tool or text_editor until the plan is submitted.", - ]) + prompt.extend( + [ + "PLANNING PHASE — SINGLE-AGENT", + "Outline the intended changes and verification in your reasoning.", + 'When ready to edit, use harness_run action="phase" phase="implement".', + "Do not create a task graph or dispatch background workers in this mode.", + ] + ) if run.task_graph: completed = [t for t in run.task_graph.sub_tasks if t.status == "completed"] @@ -132,7 +145,8 @@ def render_system_prompt( t for t in run.task_graph.sub_tasks if t.status == "pending" and t not in ready ] - has_remaining = bool(dispatched or ready or blocked) + failed = [t for t in run.task_graph.sub_tasks if t.status == "failed"] + has_remaining = bool(dispatched or ready or blocked or failed) graph_lines = [ "TASK GRAPH STATUS", @@ -151,6 +165,8 @@ def render_system_prompt( graph_lines.append('>>> NEXT ACTION: harness_run action="dispatch" to spawn parallel sub-agents <<<') if blocked: graph_lines.append("Blocked (waiting on dependencies): " + ", ".join(t.title for t in blocked)) + if failed: + graph_lines.append("Failed: " + ", ".join(t.title for t in failed)) if has_remaining: graph_lines.extend([ @@ -160,16 +176,18 @@ def render_system_prompt( "Do NOT use harness_run action=\"complete\" until all tasks are done.", "You MUST continue the dispatch → collect cycle until all tasks are completed.", ]) - if any(t.status == "failed" for t in run.task_graph.sub_tasks): + if failed: graph_lines.extend([ "", "MANUAL TAKEOVER EXCEPTION:", - "If sub-agent execution is unavailable or repeatedly failing, you may complete the remaining work yourself.", + "A worker failed or stopped at a safety boundary. Complete that work in the main chat.", "After manual completion, reconcile the graph with harness_run action=\"adopt\" for each finished sub-task.", ]) - elif completed and not has_remaining: + elif run.task_graph.is_successful(): graph_lines.append("All sub-tasks complete.") - graph_lines.append('>>> NEXT ACTION: Run tests to verify, then harness_run action="complete" <<<') + graph_lines.append( + '>>> NEXT ACTION: Run tests, record a passing verification, then use harness_run action="complete" <<<' + ) prompt.extend(graph_lines) return "\n\n".join(prompt) diff --git a/helpers/runtime.py b/helpers/runtime.py index 3aafa06..c947a85 100644 --- a/helpers/runtime.py +++ b/helpers/runtime.py @@ -6,11 +6,6 @@ """ from __future__ import annotations -import inspect -from typing import Any - -from agent import AgentContext - # Models & constants from usr.plugins.agent_harness.helpers.models import * # noqa: F401,F403 # Settings @@ -19,97 +14,15 @@ from usr.plugins.agent_harness.helpers.guardrails import * # noqa: F401,F403 # Lifecycle from usr.plugins.agent_harness.helpers.lifecycle import * # noqa: F401,F403 -# Memory (most names; accept_memory_candidate overridden below for monkeypatch compat) -from usr.plugins.agent_harness.helpers.memory import ( # noqa: F401 - propose_memory_candidate, - find_memory_candidate, - maybe_mirror_rule_to_memory, - reject_memory_candidate, -) +# Memory +from usr.plugins.agent_harness.helpers.memory import * # noqa: F401,F403 # Renderer from usr.plugins.agent_harness.helpers.renderer import * # noqa: F401,F403 # Phase 2 modules from usr.plugins.agent_harness.helpers.planner import * # noqa: F401,F403 from usr.plugins.agent_harness.helpers.orchestrator import * # noqa: F401,F403 -# Phase 3 modules -from usr.plugins.agent_harness.helpers.context_engine import * # noqa: F401,F403 +# Workspace and budget helpers from usr.plugins.agent_harness.helpers.workspace import * # noqa: F401,F403 from usr.plugins.agent_harness.helpers.cost_tracker import * # noqa: F401,F403 # Parallel dispatch from usr.plugins.agent_harness.helpers.parallel import * # noqa: F401,F403 - -# Re-import models needed for the accept_memory_candidate override -from usr.plugins.agent_harness.helpers.models import MemoryCandidate, MemoryScope -from usr.plugins.agent_harness.helpers.settings import ( - load_scope_settings as _load_scope_settings, - _merge_unique_rules as _merge_unique_rules_fn, -) -from usr.plugins.agent_harness.helpers.lifecycle import ( - get_current_run as _get_current_run, - save_current_run as _save_current_run, -) -from usr.plugins.agent_harness.helpers.memory import ( - find_memory_candidate as _find_memory_candidate, -) -from usr.plugins.agent_harness.helpers.models import now_iso as _now_iso - - -async def accept_memory_candidate( - *, - context: AgentContext, - candidate_id: str, - scope: MemoryScope, - project_name: str = "", - agent_profile: str = "", -) -> MemoryCandidate: - """Facade override — calls persist_scope_settings and maybe_mirror_rule_to_memory - through this module's namespace so monkeypatching runtime.persist_scope_settings - and runtime.maybe_mirror_rule_to_memory is interceptable by tests.""" - import sys - _this = sys.modules[__name__] - - run = _get_current_run(context) - if not run: - raise ValueError("No active harness run found") - candidate = _find_memory_candidate(run, candidate_id) - candidate.status = "accepted" - candidate.scope = scope - candidate.decided_at = _now_iso() - - scope_settings = _load_scope_settings( - scope=scope, - project_name=project_name, - agent_profile=agent_profile, - ) - accepted_rules = list(scope_settings.get("accepted_rules", [])) - accepted_rules = _merge_unique_rules_fn( - accepted_rules, - [ - { - "scope": scope, - "rule_text": candidate.rule_text, - "reason": candidate.reason, - "source": candidate.source, - "confidence": candidate.confidence, - "accepted_at": candidate.decided_at, - } - ], - ) - scope_settings["accepted_rules"] = accepted_rules - - # Call through this module's namespace so monkeypatching runtime.persist_scope_settings - # is intercepted correctly. - _this.persist_scope_settings( - scope=scope, - settings=scope_settings, - project_name=project_name, - agent_profile=agent_profile, - ) - _save_current_run(context, run) - mirror_result = _this.maybe_mirror_rule_to_memory( - agent=context.get_agent(), - candidate=candidate, - ) - if inspect.isawaitable(mirror_result): - await mirror_result - return candidate diff --git a/helpers/settings.py b/helpers/settings.py index 0ddbae7..9829ca4 100644 --- a/helpers/settings.py +++ b/helpers/settings.py @@ -12,7 +12,9 @@ normalize_harness_mode, ) -CURRENT_CONFIG_VERSION = 3 +CURRENT_CONFIG_VERSION = 4 +MAX_PARALLEL_WORKERS = 4 +MAX_REPAIR_LIMIT = 10 def load_default_settings() -> dict[str, Any]: @@ -27,6 +29,7 @@ def load_default_settings() -> dict[str, Any]: "max_auto_edit_files": 8, "dependency_install_requires_checkpoint": True, "destructive_actions_require_checkpoint": True, + "git_mutations_require_checkpoint": True, "protected_paths": ["agent.py", "initialize.py", "usr/plugins/"], "accepted_rules": [], "mode_policies": { @@ -35,6 +38,9 @@ def load_default_settings() -> dict[str, Any]: "pro": {"subagent_limit": 0, "repair_limit": 1}, "ultra": {"subagent_limit": 3, "repair_limit": 3}, }, + "workspace_enabled": True, + "token_budget": 0, + "cost_tracking_enabled": True, } @@ -175,12 +181,40 @@ def get_mode_policy( if isinstance(policies, dict) else {} ) + normalized_mode = normalize_harness_mode(mode) + subagent_limit = _bounded_int( + policy.get("subagent_limit", 0), + default=1 if normalized_mode == "ultra" else 0, + minimum=1 if normalized_mode == "ultra" else 0, + maximum=MAX_PARALLEL_WORKERS, + ) + if normalized_mode != "ultra": + subagent_limit = 0 return { - "subagent_limit": int(policy.get("subagent_limit", 0)), - "repair_limit": int(policy.get("repair_limit", 0)), + "subagent_limit": subagent_limit, + "repair_limit": _bounded_int( + policy.get("repair_limit", 0), + default=0, + minimum=0, + maximum=MAX_REPAIR_LIMIT, + ), } +def _bounded_int( + value: Any, + *, + default: int, + minimum: int, + maximum: int, +) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return min(maximum, max(minimum, parsed)) + + def get_default_mode(settings: dict[str, Any]) -> HarnessMode: return normalize_harness_mode(settings.get("default_deep_mode", DEFAULT_DEEP_MODE)) @@ -210,7 +244,12 @@ def persist_scope_settings( def check_config_version(settings: dict[str, Any]) -> bool: - return int(settings.get("config_version", 0)) >= CURRENT_CONFIG_VERSION + return _bounded_int( + settings.get("config_version", 0), + default=0, + minimum=0, + maximum=10_000, + ) >= CURRENT_CONFIG_VERSION def auto_upgrade_config(settings: dict[str, Any], settings_path: str = "") -> dict[str, Any]: diff --git a/helpers/upload_limits.py b/helpers/upload_limits.py new file mode 100644 index 0000000..568a1bd --- /dev/null +++ b/helpers/upload_limits.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + + +MAX_UPLOAD_BYTES = 25 * 1024 * 1024 +MAX_UPLOAD_BATCH_BYTES = 100 * 1024 * 1024 + + +class UploadTooLargeError(ValueError): + pass + + +def read_upload_bytes(file: Any, *, limit: int = MAX_UPLOAD_BYTES) -> bytes: + declared_size = getattr(file, "content_length", None) + try: + parsed_size = int(declared_size) if declared_size is not None else 0 + except (TypeError, ValueError): + parsed_size = 0 + if parsed_size > limit: + raise UploadTooLargeError(f"File exceeds the {format_byte_limit(limit)} limit.") + + content = file.read(limit + 1) + if len(content) > limit: + raise UploadTooLargeError(f"File exceeds the {format_byte_limit(limit)} limit.") + return content + + +def format_byte_limit(value: int) -> str: + return f"{max(0, int(value)) // (1024 * 1024)} MiB" diff --git a/helpers/workspace.py b/helpers/workspace.py index d5d5f09..c670298 100644 --- a/helpers/workspace.py +++ b/helpers/workspace.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import shutil from pathlib import Path from typing import Any @@ -8,7 +7,7 @@ from usr.plugins.agent_harness.helpers.models import WorkspacePaths WORKSPACE_ROOT = ".harness" -GITIGNORE_ENTRIES = [".harness/workspace/", ".harness/offloads/", ".harness/threads/"] +MAX_LISTED_FILES = 1_000 def _safe_context_segment(context_id: str) -> str: @@ -27,74 +26,29 @@ def ensure_workspace(project_dir: str, context_id: str = "") -> WorkspacePaths: workspace=str(workspace_dir), outputs=str(outputs_dir), uploads=str(uploads_dir), - offloads=str(root / "offloads"), - runs=str(root / "runs"), thread_root=str(thread_root) if thread_root else "", user_data=str(user_data) if user_data else "", ) - for p in [paths.workspace, paths.outputs, paths.uploads, paths.offloads, paths.runs]: + for p in [paths.workspace, paths.outputs, paths.uploads]: Path(p).mkdir(parents=True, exist_ok=True) return paths -def ensure_gitignore(project_dir: str) -> None: - gitignore_path = Path(project_dir) / ".gitignore" - existing = gitignore_path.read_text() if gitignore_path.exists() else "" - lines_to_add = [e for e in GITIGNORE_ENTRIES if e not in existing] - if lines_to_add: - suffix = "\n" if existing and not existing.endswith("\n") else "" - gitignore_path.write_text( - existing + suffix + "\n".join(lines_to_add) + "\n" - ) - - -def sub_task_workspace(paths: WorkspacePaths, sub_task_id: str) -> str: - p = Path(paths.workspace) / sub_task_id - p.mkdir(parents=True, exist_ok=True) - return str(p) - - -def write_offload(paths: WorkspacePaths, offload_id: str, content: str) -> str: - filepath = Path(paths.offloads) / f"{offload_id}.md" - filepath.write_text(content) - return str(filepath) - - -def write_run_log(paths: WorkspacePaths, run_data: dict) -> str: - filepath = Path(paths.runs) / f"{run_data.get('run_id', 'unknown')}.json" - filepath.write_text(json.dumps(run_data, indent=2)) - return str(filepath) - - def clean_workspace(paths: WorkspacePaths) -> None: - for p in [paths.workspace, paths.offloads]: - if Path(p).exists(): - shutil.rmtree(p) + if Path(paths.workspace).exists(): + shutil.rmtree(paths.workspace) def save_upload(paths: WorkspacePaths, filename: str, content: bytes) -> str: - filepath = Path(paths.uploads) / filename - filepath.parent.mkdir(parents=True, exist_ok=True) + if not filename or Path(filename).name != filename or filename in {".", ".."}: + raise ValueError("Upload filename must be a single safe path segment") + filepath = resolve_upload(paths, filename, create_parent=True) filepath.write_bytes(content) return str(filepath) def list_uploads(paths: WorkspacePaths) -> list[dict[str, Any]]: - uploads_root = Path(paths.uploads) - if not uploads_root.exists(): - return [] - results: list[dict[str, Any]] = [] - for file_path in sorted(p for p in uploads_root.rglob("*") if p.is_file()): - relative = file_path.relative_to(uploads_root).as_posix() - results.append( - { - "name": file_path.name, - "path": relative, - "abs_path": str(file_path), - "size": file_path.stat().st_size, - } - ) - return results + return _list_safe_files(Path(paths.uploads)) def delete_upload(paths: WorkspacePaths, relative_path: str) -> bool: @@ -121,21 +75,7 @@ def resolve_upload( def list_artifacts(paths: WorkspacePaths) -> list[dict[str, Any]]: - outputs_root = Path(paths.outputs) - if not outputs_root.exists(): - return [] - results: list[dict[str, Any]] = [] - for file_path in sorted(p for p in outputs_root.rglob("*") if p.is_file()): - relative = file_path.relative_to(outputs_root).as_posix() - results.append( - { - "name": file_path.name, - "path": relative, - "abs_path": str(file_path), - "size": file_path.stat().st_size, - } - ) - return results + return _list_safe_files(Path(paths.outputs)) def resolve_artifact(paths: WorkspacePaths, relative_path: str) -> Path: @@ -149,3 +89,33 @@ def resolve_artifact(paths: WorkspacePaths, relative_path: str) -> Path: def cleanup_thread_data(paths: WorkspacePaths) -> None: if paths.thread_root and Path(paths.thread_root).exists(): shutil.rmtree(paths.thread_root) + + +def _list_safe_files(root: Path) -> list[dict[str, Any]]: + try: + resolved_root = root.resolve() + except (OSError, RuntimeError): + return [] + if not resolved_root.exists(): + return [] + + results: list[dict[str, Any]] = [] + for file_path in sorted(resolved_root.rglob("*")): + try: + resolved_file = file_path.resolve(strict=True) + if not resolved_file.is_file() or not resolved_file.is_relative_to(resolved_root): + continue + relative = file_path.relative_to(resolved_root).as_posix() + size = resolved_file.stat().st_size + except (OSError, RuntimeError, ValueError): + continue + results.append( + { + "name": file_path.name, + "path": relative, + "size": size, + } + ) + if len(results) >= MAX_LISTED_FILES: + break + return results diff --git a/hooks.py b/hooks.py new file mode 100644 index 0000000..ac3cbe9 --- /dev/null +++ b/hooks.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import importlib +from pathlib import Path +import shutil +import sys + +PLUGIN_PACKAGE_PREFIX = "usr.plugins.agent_harness" +PLUGIN_ROOT = Path(__file__).resolve().parent + + +def install() -> None: + """Finish installation or update without a separate Execute step.""" + _stop_workers() + _clear_plugin_modules() + _clear_plugin_bytecode() + + +def pre_update() -> None: + _stop_workers() + _clear_plugin_modules() + _clear_plugin_bytecode() + + +def uninstall() -> None: + _stop_workers() + _clear_plugin_modules() + _clear_plugin_bytecode() + + +def _stop_workers() -> None: + try: + from usr.plugins.agent_harness.helpers.parallel import kill_all_runs + + kill_all_runs() + except ImportError: + pass + + +def _clear_plugin_modules() -> None: + for module_name in list(sys.modules): + if module_name == PLUGIN_PACKAGE_PREFIX or module_name.startswith( + f"{PLUGIN_PACKAGE_PREFIX}." + ): + sys.modules.pop(module_name, None) + importlib.invalidate_caches() + + +def _clear_plugin_bytecode() -> None: + for cache_dir in PLUGIN_ROOT.rglob("__pycache__"): + shutil.rmtree(cache_dir, ignore_errors=True) diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..66dd78b --- /dev/null +++ b/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "Agent Harness", + "description": "Structured coding workflows, action-bound safety gates, and Ultra-only parallel task execution for Agent Zero.", + "version": "2.0.0", + "settings_sections": [ + "agent", + "developer" + ], + "per_project_config": true, + "per_agent_config": true, + "always_enabled": false +} diff --git a/plugin.yaml b/plugin.yaml index 67eac66..7e3e015 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,7 +1,7 @@ name: agent_harness title: Agent Harness -description: Deep-task harness controls, review checkpoints, and runtime status for Agent Zero. -version: 1.0.0 +description: Coding workflows, action-bound safety checkpoints, and Ultra-only parallel task execution for Agent Zero. +version: 2.0.0 settings_sections: - agent - developer diff --git a/prompts/agent.system.tool.harness_checkpoint.md b/prompts/agent.system.tool.harness_checkpoint.md index 9c2477c..51d4843 100644 --- a/prompts/agent.system.tool.harness_checkpoint.md +++ b/prompts/agent.system.tool.harness_checkpoint.md @@ -3,7 +3,7 @@ request a mandatory checkpoint before risky actions — the user must approve be #### WHEN TO USE (MANDATORY — do not skip): - Before running pip install, npm install, apt-get install, or any package manager -- Before git push, git reset --hard, or any destructive git operation +- Before git commit, push, merge, rebase, branch, tag, stash, reset, or another repository-changing Git operation - Before rm -rf or any recursive file deletion - Before modifying files in protected paths (agent.py, initialize.py, usr/plugins/) - Before any action that cannot be easily undone @@ -22,7 +22,16 @@ usage: "tool_args": { "reason": "Need to install the 'rich' library for terminal formatting.", "proposed_action": "pip install rich", - "risk_level": "high" + "risk_level": "high", + "target_tool_name": "code_execution_tool", + "target_tool_args": { + "runtime": "terminal", + "code": "pip install rich" + } } } ~~~ + +Approval is action-bound and single-use. After approval, run the exact target +tool once. Do not alter its arguments and do not retry it without requesting a +new checkpoint. diff --git a/prompts/agent.system.tool.harness_run.md b/prompts/agent.system.tool.harness_run.md index 95558d0..aac613f 100644 --- a/prompts/agent.system.tool.harness_run.md +++ b/prompts/agent.system.tool.harness_run.md @@ -1,46 +1,63 @@ ### harness_run -manage the active agent harness run — this is your primary workflow orchestration tool +manage the active Agent Harness run -#### WORKFLOW: plan → dispatch → collect → verify → complete -For any task that requires creating or modifying 2+ files: -1. Use `action="plan"` to decompose into sub-tasks FIRST -2. Use `action="dispatch"` to spawn parallel sub-agents for ready tasks -3. Use `action="collect"` to harvest results as sub-agents finish -4. Repeat dispatch/collect until all sub-tasks complete -5. Run tests and use `action="verification"` to record results -6. Use `action="complete"` to finish the run +The active runtime prompt defines the current mode and phase. Follow it exactly. -Do NOT skip planning and implement everything yourself under normal conditions. Sub-agents run in parallel and are faster. -If sub-agent execution is unavailable or repeatedly failing, you may take over the work yourself and then use `action="adopt"` to reconcile the completed sub-task back into the graph. +#### Mode boundary -#### harness_run actions -- `start`: begin a harness run with `mode`, `objective`, and optional `constraints` -- `phase`: update the current phase with `phase` -- `plan`: submit a task graph — REQUIRED before implementing multi-file work -- `dispatch`: spawn parallel sub-agents for ready tasks (up to mode's subagent_limit) -- `collect`: check progress and harvest results from parallel sub-agents -- `adopt`: mark a planned sub-task as completed manually using `sub_task_id`, optional `summary`, and optional `result_files` -- `task`: track a subtask using `task_title`, optional `task_status`, and optional `task_details` -- `verification`: record a verification result with `verification_name`, `verification_status` (must be "passed", "failed", or "unknown"), and `verification_summary` -- `failure`: note a failure summary when a repair loop needs context -- `complete`: mark the current run complete after verification and summary -- `status`: read back the current run state -- `clean`: remove temporary workspace files (keeps outputs and run logs) +- `flash`, `standard`, and `pro` are single-agent modes. Do not use `plan`, + `dispatch`, `collect`, or `adopt` in those modes. +- `ultra` is the task-graph mode. It requires + `plan -> dispatch -> collect -> verify -> complete`. +- Parallel workers share the project workspace. Only plan tasks whose edits do + not overlap. A worker that reaches an approval boundary stops so the main chat + can perform that action safely. + +#### Actions + +- `start`: begin a run with `mode`, `objective`, and optional `constraints` +- `status`: summarize the current run +- `phase`: move to a valid lifecycle phase +- `plan`: Ultra only; submit a non-empty task graph +- `dispatch`: Ultra only; start ready tasks up to the configured worker limit +- `collect`: Ultra only; harvest completed worker results +- `adopt`: Ultra only; reconcile a failed or manually completed task with + `sub_task_id`, optional `summary`, and optional `result_files` +- `task`: track a single-agent task item with `task_title`, optional + `task_status`, and optional `task_details` +- `verification`: record a concrete check using `verification_name`, + `verification_status` (`passed`, `failed`, or `unknown`), and + `verification_summary` +- `failure`: record a bounded repair failure +- `clean`: clear temporary harness workspace data +- `complete`: finish only after every planned task is complete and the latest + verification passed + +Ultra plan example: -usage: ~~~json { - "thoughts": [ - "This task requires multiple files. I need to plan before implementing." - ], - "headline": "Planning the implementation", "tool_name": "harness_run", "tool_args": { "action": "plan", "sub_tasks": [ - {"title": "Research existing patterns", "description": "Read the codebase to understand conventions", "role": "research"}, - {"title": "Implement core module", "description": "Create the main module with business logic", "role": "code", "depends_on": [0]}, - {"title": "Write tests", "description": "Create comprehensive tests", "role": "verify", "depends_on": [1]} + { + "title": "Research existing patterns", + "description": "Read the relevant code and report constraints", + "role": "research" + }, + { + "title": "Implement the fix", + "description": "Change the isolated implementation files", + "role": "code", + "depends_on": [0] + }, + { + "title": "Verify behavior", + "description": "Run the focused regression checks", + "role": "verify", + "depends_on": [1] + } ] } } diff --git a/prompts/agent.system.tool.harness_run.plan.md b/prompts/agent.system.tool.harness_run.plan.md index 7c0b972..7c2e782 100644 --- a/prompts/agent.system.tool.harness_run.plan.md +++ b/prompts/agent.system.tool.harness_run.plan.md @@ -1,27 +1,16 @@ -### Planning with harness_run +### Ultra planning with harness_run -When the harness is in `plan` phase, decompose the objective into sub-tasks using `harness_run action="plan"`. +`harness_run action="plan"` is available only in Ultra mode. -Each sub-task should be independently executable by a sub-agent. Use roles: -- `research`: gather information, read docs, explore code -- `code`: implement features, fix bugs, write code -- `verify`: run tests, validate output -- `synthesize`: combine results from other sub-tasks +Create at least one scoped sub-task. Use roles: -Reference dependencies by index (0-based). Example: +- `research`: read code or primary documentation and report evidence +- `code`: implement a non-overlapping change +- `verify`: run focused checks +- `synthesize`: combine completed dependency results -~~~json -{ - "tool_name": "harness_run", - "tool_args": { - "action": "plan", - "sub_tasks": [ - {"title": "Research Stripe API", "description": "Read webhook documentation", "role": "research"}, - {"title": "Implement handler", "description": "Write webhook endpoint", "role": "code", "depends_on": [0]}, - {"title": "Write tests", "description": "Test the handler", "role": "verify", "depends_on": [1]} - ] - } -} -~~~ - -After planning, use `action="dispatch"` to get dispatch instructions for ready sub-tasks. +Dependencies are zero-based indexes into the submitted list. Parallel workers +share the same filesystem, so never dispatch code tasks that may edit the same +files. After the plan is accepted, use `dispatch`, then `collect`, until every +task is complete. Run a final integrated verification in the main chat before +completing the run. diff --git a/scripts/check_deerflow_harness.py b/scripts/check_deerflow_harness.py deleted file mode 100755 index 8436672..0000000 --- a/scripts/check_deerflow_harness.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - - -def _project_root() -> Path: - return Path(__file__).resolve().parents[4] - - -PROJECT_ROOT = _project_root() -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from usr.plugins.agent_harness.helpers.deerflow_sync import ( # noqa: E402 - collect_plugin_asset_status, - list_public_skills, -) -from usr.plugins.agent_harness.helpers.settings import load_default_settings # noqa: E402 - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Check whether the Agent Harness plugin has the DeerFlow bootstrap surface in place." - ) - parser.add_argument( - "--source", - help="Optional DeerFlow repo root or skills/public path to validate against.", - ) - args = parser.parse_args() - - plugin_root = PROJECT_ROOT / "usr" / "plugins" / "agent_harness" - asset_status = collect_plugin_asset_status(plugin_root) - defaults = load_default_settings() - - print("Agent Harness DeerFlow Check") - print(f"Plugin root: {plugin_root}") - for name, present in asset_status.items(): - label = name.replace("_", " ") - mark = "OK" if present else "MISSING" - print(f"- {label}: {mark}") - - print("") - modes = list( - dict.fromkeys( - [ - str(defaults.get("default_deep_mode", "pro")), - "flash", - "standard", - "pro", - "ultra", - ] - ) - ) - print("Modes: " + ", ".join(modes)) - - if args.source: - print("") - try: - skills = list_public_skills(args.source) - except FileNotFoundError as exc: - print(f"DeerFlow source: INVALID ({exc})") - return 1 - print(f"DeerFlow source: OK ({len(skills)} public skills found)") - if skills: - print("Sample skills: " + ", ".join(skills[:8])) - - if not all(asset_status.values()): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/import_deerflow_public_skills.py b/scripts/import_deerflow_public_skills.py deleted file mode 100755 index 2dd0620..0000000 --- a/scripts/import_deerflow_public_skills.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import sys -from pathlib import Path - - -def _project_root() -> Path: - return Path(__file__).resolve().parents[4] - - -PROJECT_ROOT = _project_root() -if str(PROJECT_ROOT) not in sys.path: - sys.path.insert(0, str(PROJECT_ROOT)) - -from usr.plugins.agent_harness.helpers.deerflow_sync import ( # noqa: E402 - DEFAULT_NAMESPACE, - import_public_skills, - list_public_skills, -) - - -def main() -> int: - parser = argparse.ArgumentParser( - description="Import DeerFlow public skills into Agent Zero's skills registry." - ) - parser.add_argument( - "--source", - required=True, - help="Path to a DeerFlow repo root, skills directory, or skills/public directory.", - ) - parser.add_argument( - "--namespace", - default=DEFAULT_NAMESPACE, - help=f"Destination namespace under usr/skills (default: {DEFAULT_NAMESPACE}).", - ) - parser.add_argument( - "--conflict", - choices=["skip", "overwrite", "rename"], - default="skip", - help="Conflict policy for existing imported skills.", - ) - parser.add_argument("--project-name", help="Optional project-scoped destination.") - parser.add_argument("--agent-profile", help="Optional agent-profile-scoped destination.") - args = parser.parse_args() - - available = list_public_skills(args.source) - result = import_public_skills( - args.source, - namespace=args.namespace, - conflict=args.conflict, - project_name=args.project_name, - agent_profile=args.agent_profile, - ) - - print("Imported DeerFlow public skills") - print(f"- Source skill count: {len(available)}") - print(f"- Imported: {len(result.imported)}") - print(f"- Skipped: {len(result.skipped)}") - print(f"- Namespace: {result.namespace}") - print(f"- Destination: {result.destination_root / result.namespace}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..6a92690 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +from collections import OrderedDict +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +import sys +from types import ModuleType + + +PROJECT_ROOT = Path( + os.getenv("A0_TEST_PROJECT_ROOT", Path(__file__).resolve().parents[4]) +).resolve() +PLUGIN_ROOT = Path(__file__).resolve().parents[1] + +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +def _install_core_stubs() -> None: + helpers = ModuleType("helpers") + helpers.__path__ = [] # type: ignore[attr-defined] + + files = ModuleType("helpers.files") + files.USER_DIR = "usr" + files.PLUGINS_DIR = "plugins" + files.get_abs_path = lambda *parts: str(Path(*map(str, parts))) + files.exists = lambda path: Path(path).exists() + files.read_file = lambda path: Path(path).read_text(encoding="utf-8") + + plugins = ModuleType("helpers.plugins") + plugins.CONFIG_DEFAULT_FILE_NAME = "default_config.yaml" + plugins.CONFIG_FILE_NAME = "config.json" + plugins.find_plugin_dir = ( + lambda plugin_name: str(PLUGIN_ROOT) if plugin_name == "agent_harness" else "" + ) + plugins.determine_plugin_asset_path = ( + lambda plugin_name, project_name, agent_profile, filename: str( + PLUGIN_ROOT / filename + ) + ) + + projects = ModuleType("helpers.projects") + projects.get_context_project_name = lambda context: "" + + yaml_helper = ModuleType("helpers.yaml") + yaml_helper.loads = lambda value: {} + + extension = ModuleType("helpers.extension") + + class Extension: + def __init__(self, agent=None): + self.agent = agent + + extension.Extension = Extension + + helpers.files = files + helpers.plugins = plugins + helpers.projects = projects + helpers.yaml = yaml_helper + helpers.extension = extension + + agent = ModuleType("agent") + + class AgentContext: + def __init__(self): + self.data = {} + self.output_data = {} + self.id = "test-context" + + def get_data(self, key, recursive=True): + return self.data.get(key) + + def set_data(self, key, value, recursive=True): + self.data[key] = value + + def set_output_data(self, key, value, recursive=True): + self.output_data[key] = value + + @staticmethod + def remove(context_id): + return None + + class Agent: + DATA_NAME_SUPERIOR = "_superior" + DATA_NAME_SUBORDINATE = "_subordinate" + + def __init__(self, context=None): + self.context = context or AgentContext() + + class LoopData: + def __init__(self): + self.extras_persistent = OrderedDict() + + class AgentContextType(Enum): + USER = "user" + BACKGROUND = "background" + + @dataclass + class UserMessage: + message: str + attachments: list[str] + + agent.Agent = Agent + agent.AgentContext = AgentContext + agent.AgentContextType = AgentContextType + agent.LoopData = LoopData + agent.UserMessage = UserMessage + + defer = ModuleType("helpers.defer") + + class DeferredTask: + def __init__(self, thread_name="Background"): + self.thread_name = thread_name + + defer.DeferredTask = DeferredTask + helpers.defer = defer + + initialize = ModuleType("initialize") + initialize.initialize_agent = lambda: object() + + sys.modules.update( + { + "helpers": helpers, + "helpers.files": files, + "helpers.plugins": plugins, + "helpers.projects": projects, + "helpers.yaml": yaml_helper, + "helpers.extension": extension, + "helpers.defer": defer, + "agent": agent, + "initialize": initialize, + } + ) + + +if os.getenv("A0_TEST_USE_REAL_CORE") != "1": + _install_core_stubs() diff --git a/tests/test_guardrails.py b/tests/test_guardrails.py new file mode 100644 index 0000000..480ff70 --- /dev/null +++ b/tests/test_guardrails.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import unittest + +import support # noqa: F401 + +from usr.plugins.agent_harness.helpers.guardrails import ( + assess_tool_guardrail_decision, + decide_checkpoint, + request_checkpoint, +) +from usr.plugins.agent_harness.helpers.lifecycle import create_run_record + + +SETTINGS = { + "dependency_install_requires_checkpoint": True, + "destructive_actions_require_checkpoint": True, + "git_mutations_require_checkpoint": True, + "max_auto_edit_files": 8, + "protected_paths": ["agent.py", "initialize.py", "usr/plugins/"], + "mode_policies": { + "pro": {"subagent_limit": 0, "repair_limit": 1}, + }, +} + + +def make_run(): + return create_run_record( + context_id="ctx", + mode="pro", + objective="Test the guardrails", + constraints=[], + settings=SETTINGS, + ) + + +class GuardrailTests(unittest.TestCase): + def test_approved_command_is_action_bound_and_single_use(self): + run = make_run() + checkpoint = request_checkpoint( + run, + reason="Install requirements", + proposed_action="pip install -r requirements.txt", + tool_name="code_execution_tool", + tool_args={ + "runtime": "terminal", + "code": "pip install -r requirements.txt", + }, + risk_level="high", + ) + decide_checkpoint( + run, + checkpoint_id=checkpoint.id, + decision="approved", + ) + + actual_args = { + "runtime": "terminal", + "code": "pip install -r requirements.txt", + "session": 0, + "reset": False, + "allow_running": False, + } + first = assess_tool_guardrail_decision( + run=run, + tool_name="code_execution_tool", + tool_args=actual_args, + settings=SETTINGS, + ) + second = assess_tool_guardrail_decision( + run=run, + tool_name="code_execution_tool", + tool_args=actual_args, + settings=SETTINGS, + ) + + self.assertIs(first.approved_checkpoint, checkpoint) + self.assertTrue(checkpoint.consumed_at) + self.assertIs(second.denied_checkpoint, checkpoint) + self.assertIn("already consumed", second.denial_reason) + + def test_pending_checkpoint_stops_unrelated_tools(self): + run = make_run() + checkpoint = request_checkpoint( + run, + reason="Install dependency", + proposed_action="pip install rich", + tool_name="code_execution_tool", + tool_args={"runtime": "terminal", "code": "pip install rich"}, + risk_level="high", + ) + + assessment = assess_tool_guardrail_decision( + run=run, + tool_name="text_editor", + tool_args={"path": "README.md"}, + settings=SETTINGS, + ) + + self.assertIs(assessment.denied_checkpoint, checkpoint) + self.assertIn("still pending", assessment.denial_reason) + + def test_protected_directory_uses_path_boundaries(self): + protected_run = make_run() + protected = assess_tool_guardrail_decision( + run=protected_run, + tool_name="text_editor", + tool_args={ + "action": "patch", + "path": "usr/plugins/example/main.py", + "old_text": "old", + "new_text": "new", + }, + settings=SETTINGS, + ) + + similar_run = make_run() + similar = assess_tool_guardrail_decision( + run=similar_run, + tool_name="text_editor", + tool_args={ + "action": "patch", + "path": "usr/plugins_backup/example/main.py", + "old_text": "old", + "new_text": "new", + }, + settings=SETTINGS, + ) + + self.assertIsNotNone(protected.checkpoint) + self.assertIsNone(similar.checkpoint) + + def test_reading_a_protected_path_does_not_request_approval(self): + run = make_run() + + assessment = assess_tool_guardrail_decision( + run=run, + tool_name="text_editor", + tool_args={"action": "read", "path": "agent.py"}, + settings=SETTINGS, + ) + + self.assertIsNone(assessment.checkpoint) + + def test_read_only_git_is_allowed_but_commit_requires_approval(self): + status_run = make_run() + status = assess_tool_guardrail_decision( + run=status_run, + tool_name="code_execution_tool", + tool_args={"runtime": "terminal", "code": "git status --short"}, + settings=SETTINGS, + ) + + commit_run = make_run() + commit = assess_tool_guardrail_decision( + run=commit_run, + tool_name="code_execution_tool", + tool_args={"runtime": "terminal", "code": "git commit -m 'fix'"}, + settings=SETTINGS, + ) + + self.assertIsNone(status.checkpoint) + self.assertIsNotNone(commit.checkpoint) + self.assertIn("repository-changing Git", commit.checkpoint.reason) + + def test_python_module_pip_install_requires_approval(self): + run = make_run() + + assessment = assess_tool_guardrail_decision( + run=run, + tool_name="code_execution_tool", + tool_args={ + "runtime": "terminal", + "code": "python3 -m pip install -r requirements.txt", + }, + settings=SETTINGS, + ) + + self.assertIsNotNone(assessment.checkpoint) + self.assertIn("dependency install", assessment.checkpoint.reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle_and_renderer.py b/tests/test_lifecycle_and_renderer.py new file mode 100644 index 0000000..a4a14e1 --- /dev/null +++ b/tests/test_lifecycle_and_renderer.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import unittest + +import support # noqa: F401 + +from usr.plugins.agent_harness.helpers.lifecycle import ( + complete_run, + completion_blocker, + create_run_record, + parse_verification_status, + record_verification, +) +from usr.plugins.agent_harness.helpers.planner import ( + mark_sub_task_completed, + submit_plan, +) +from usr.plugins.agent_harness.helpers.renderer import render_system_prompt +from usr.plugins.agent_harness.helpers.settings import get_mode_policy + + +SETTINGS = { + "ambient_assist_enabled": True, + "mode_policies": { + "flash": {"subagent_limit": 0, "repair_limit": 0}, + "standard": {"subagent_limit": 0, "repair_limit": 1}, + "pro": {"subagent_limit": 0, "repair_limit": 1}, + "ultra": {"subagent_limit": 3, "repair_limit": 3}, + }, +} + + +def make_run(mode: str = "pro"): + return create_run_record( + context_id="ctx", + mode=mode, # type: ignore[arg-type] + objective="Ship a safe fix", + constraints=[], + settings=SETTINGS, + ) + + +class LifecycleAndRendererTests(unittest.TestCase): + def test_ambient_assist_renders_without_a_run(self): + prompt = render_system_prompt( + settings=SETTINGS, + run=None, + accepted_rules=[{"rule_text": "Never edit generated files."}], + ) + + self.assertIn("AMBIENT ASSIST", prompt) + self.assertIn("Never edit generated files", prompt) + + def test_pro_plan_phase_does_not_demand_parallel_task_graph(self): + run = make_run("pro") + run.phase = "plan" + + prompt = render_system_prompt( + settings=SETTINGS, + run=run, + accepted_rules=[], + ) + + self.assertIn("PLANNING PHASE — SINGLE-AGENT", prompt) + self.assertNotIn('action="dispatch"', prompt) + + def test_ultra_plan_requires_independent_task_graph(self): + run = make_run("ultra") + run.phase = "plan" + + prompt = render_system_prompt( + settings=SETTINGS, + run=run, + accepted_rules=[], + ) + + self.assertIn("DECOMPOSE INDEPENDENT WORK", prompt) + self.assertIn('action="plan"', prompt) + + def test_workers_are_exclusive_to_ultra_and_bounded(self): + settings = { + "mode_policies": { + "pro": {"subagent_limit": 4, "repair_limit": 1}, + "ultra": {"subagent_limit": 99, "repair_limit": 3}, + } + } + + self.assertEqual(get_mode_policy(settings, "pro")["subagent_limit"], 0) + self.assertEqual(get_mode_policy(settings, "ultra")["subagent_limit"], 4) + + def test_common_test_runner_summaries_are_recognized(self): + self.assertEqual( + parse_verification_status("Ran 4 tests in 0.1s\n\nOK\n"), + "passed", + ) + self.assertEqual( + parse_verification_status("test result: FAILED. 2 passed; 1 failed"), + "failed", + ) + + def test_completion_requires_successful_tasks_and_passing_verification(self): + run = make_run("ultra") + graph = submit_plan( + run, + [ + { + "title": "Implement", + "description": "Make the change", + "role": "code", + "depends_on": [], + } + ], + ) + + self.assertIn("unfinished", completion_blocker(run)) + complete_run(run) + self.assertEqual(graph.sub_tasks[0].status, "pending") + + run.status = "active" + run.phase = "implement" + mark_sub_task_completed(run, "st_1", summary="Implemented") + self.assertIn("passing verification", completion_blocker(run)) + + record_verification( + run, + name="unit tests", + status="passed", + summary="12 tests passed", + ) + self.assertEqual(completion_blocker(run), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..f2f1f2b --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import support # noqa: F401 + +from usr.plugins.agent_harness.helpers.lifecycle import ( + create_run_record, + save_current_run, +) +from usr.plugins.agent_harness.helpers.memory import ( + accept_memory_candidate, + propose_memory_candidate, +) + + +class Context: + def __init__(self): + self.id = "memory-test" + self.data = {} + self.output_data = {} + + def get_data(self, key, recursive=True): + return self.data.get(key) + + def set_data(self, key, value, recursive=True): + self.data[key] = value + + def set_output_data(self, key, value, recursive=True): + self.output_data[key] = value + + +class MemoryTests(unittest.IsolatedAsyncioTestCase): + async def test_accepted_rule_persists_only_to_selected_harness_scope(self): + context = Context() + run = create_run_record( + context_id=context.id, + mode="pro", + objective="Remember a rule", + constraints=[], + settings={"mode_policies": {}}, + ) + candidate = propose_memory_candidate( + run=run, + rule_text="Run the focused test first.", + reason="Fast feedback", + source="test", + scope="agent", + confidence=0.9, + ) + save_current_run(context, run) + + with ( + patch( + "usr.plugins.agent_harness.helpers.memory.load_scope_settings", + return_value={}, + ), + patch( + "usr.plugins.agent_harness.helpers.memory.persist_scope_settings", + ) as persist, + ): + accepted = await accept_memory_candidate( + context=context, + candidate_id=candidate.id, + scope="agent", + agent_profile="developer", + ) + + self.assertEqual(accepted.status, "accepted") + persisted = persist.call_args.kwargs + self.assertEqual(persisted["scope"], "agent") + self.assertEqual(persisted["agent_profile"], "developer") + self.assertEqual( + persisted["settings"]["accepted_rules"][0]["rule_text"], + "Run the focused test first.", + ) + + async def test_invalid_scope_is_rejected(self): + context = Context() + + with self.assertRaisesRegex(ValueError, "Memory scope"): + await accept_memory_candidate( + context=context, + candidate_id="missing", + scope="invalid", # type: ignore[arg-type] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_parallel.py b/tests/test_parallel.py new file mode 100644 index 0000000..dc5a227 --- /dev/null +++ b/tests/test_parallel.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from copy import deepcopy +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +import support # noqa: F401 + +from usr.plugins.agent_harness.helpers import parallel +from usr.plugins.agent_harness.helpers.lifecycle import create_run_record +from usr.plugins.agent_harness.helpers.models import ( + PARALLEL_WORKER_CONTEXT_KEY, + RUN_CONTEXT_KEY, + SubTask, +) + + +SETTINGS = { + "mode_policies": { + "ultra": {"subagent_limit": 3, "repair_limit": 3}, + "flash": {"subagent_limit": 0, "repair_limit": 0}, + } +} + + +class FakeAgent: + def __init__(self, context): + self.context = context + self.messages = [] + + def hist_add_user_message(self, message): + self.messages.append(message) + + async def monologue(self): + return "done" + + +class FakeContext: + next_id = 0 + removed = [] + + def __init__(self, config, type=None, set_current=False, data=None): + self.__class__.next_id += 1 + self.id = f"worker-{self.__class__.next_id}" + self.config = config + self.data = data or {} + self.output_data = {} + self.agent0 = FakeAgent(self) + + def get_data(self, key, recursive=True): + return self.data.get(key) + + def set_data(self, key, value, recursive=True): + self.data[key] = value + + def set_output_data(self, key, value, recursive=True): + self.output_data[key] = value + + @classmethod + def remove(cls, context_id): + cls.removed.append(context_id) + + +class FakeDeferred: + def __init__(self, thread_name="Background"): + self.thread_name = thread_name + self.started = False + self.killed = False + + def start_task(self, function): + self.started = True + self.function = function + return self + + def is_ready(self): + return False + + def kill(self, terminate_thread=False): + self.killed = bool(terminate_thread) + + +def make_run(run_id: str): + run = create_run_record( + context_id=f"ctx-{run_id}", + mode="ultra", + objective="Parallel test", + constraints=[], + settings=SETTINGS, + ) + run.run_id = run_id + return run + + +def make_task(): + return SubTask( + id="st_1", + title="Inspect", + description="Read the relevant code", + role="research", + ) + + +class ParallelTests(unittest.TestCase): + def setUp(self): + parallel._active_tasks.clear() + FakeContext.removed = [] + + def tearDown(self): + parallel._active_tasks.clear() + + def test_same_subtask_id_is_isolated_between_runs(self): + parent = SimpleNamespace( + config=SimpleNamespace(profile="selected"), + data={"model": {"provider": "test"}}, + ) + first_run = make_run("run-a") + second_run = make_run("run-b") + first_task = make_task() + second_task = make_task() + + with ( + patch.object(parallel, "AgentContext", FakeContext), + patch.object(parallel, "DeferredTask", FakeDeferred), + ): + parallel.spawn_parallel( + first_run, + [first_task], + SETTINGS, + parent_context=parent, + ) + parallel.spawn_parallel( + second_run, + [second_task], + SETTINGS, + parent_context=parent, + ) + + self.assertEqual(parallel.registered_task_ids("run-a"), {"st_1"}) + self.assertEqual(parallel.registered_task_ids("run-b"), {"st_1"}) + self.assertEqual(len(parallel._active_tasks), 2) + + first_worker = parallel._active_tasks["run-a:st_1"] + self.assertEqual(first_worker.context.config.profile, "selected") + self.assertEqual( + first_worker.context.data["model"], + deepcopy(parent.data["model"]), + ) + self.assertIn(RUN_CONTEXT_KEY, first_worker.context.data) + self.assertEqual( + first_worker.context.data[PARALLEL_WORKER_CONTEXT_KEY]["sub_task_id"], + "st_1", + ) + + self.assertEqual(parallel.kill_all("run-a"), 1) + self.assertEqual(parallel.registered_task_ids("run-a"), set()) + self.assertEqual(parallel.registered_task_ids("run-b"), {"st_1"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prompt_extension.py b/tests/test_prompt_extension.py new file mode 100644 index 0000000..03d6caf --- /dev/null +++ b/tests/test_prompt_extension.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import support # noqa: F401 + +from agent import LoopData +from usr.plugins.agent_harness.extensions.python.message_loop_prompts_after._20_harness_runtime import ( + HarnessRuntimePrompt, +) + + +class Context: + def get_data(self, key, recursive=True): + return None + + +class Agent: + def __init__(self): + self.context = Context() + self.last_user_message = None + + +class PromptExtensionTests(unittest.IsolatedAsyncioTestCase): + async def test_ambient_prompt_is_injected_without_an_active_run(self): + loop_data = LoopData() + extension = HarnessRuntimePrompt(agent=Agent()) + settings = { + "ambient_assist_enabled": True, + "accepted_rules": [{"rule_text": "Verify the same delivery surface."}], + } + + with ( + patch( + "usr.plugins.agent_harness.extensions.python.message_loop_prompts_after._20_harness_runtime.load_agent_settings", + return_value=settings, + ), + patch( + "usr.plugins.agent_harness.extensions.python.message_loop_prompts_after._20_harness_runtime.get_current_run", + return_value=None, + ), + ): + await extension.execute(loop_data=loop_data) + + prompt = loop_data.extras_persistent["agent_harness_runtime"] + self.assertIn("AMBIENT ASSIST", prompt) + self.assertIn("Verify the same delivery surface", prompt) + + async def test_disabled_ambient_assist_removes_stale_prompt(self): + loop_data = LoopData() + loop_data.extras_persistent["agent_harness_runtime"] = "stale" + extension = HarnessRuntimePrompt(agent=Agent()) + + with ( + patch( + "usr.plugins.agent_harness.extensions.python.message_loop_prompts_after._20_harness_runtime.load_agent_settings", + return_value={"ambient_assist_enabled": False}, + ), + patch( + "usr.plugins.agent_harness.extensions.python.message_loop_prompts_after._20_harness_runtime.get_current_run", + return_value=None, + ), + ): + await extension.execute(loop_data=loop_data) + + self.assertNotIn("agent_harness_runtime", loop_data.extras_persistent) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repository_contract.py b/tests/test_repository_contract.py new file mode 100644 index 0000000..af77253 --- /dev/null +++ b/tests/test_repository_contract.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + +import support + + +PLUGIN_ROOT = support.PLUGIN_ROOT + + +class RepositoryContractTests(unittest.TestCase): + def test_standalone_plugin_metadata_is_present(self): + for filename in ( + "plugin.yaml", + "plugin.json", + "README.md", + "LICENSE", + "hooks.py", + ): + self.assertTrue((PLUGIN_ROOT / filename).is_file(), filename) + manifest = (PLUGIN_ROOT / "plugin.yaml").read_text(encoding="utf-8") + self.assertIn("version: 2.0.0", manifest) + + def test_runtime_config_is_not_tracked_as_distribution_content(self): + self.assertFalse((PLUGIN_ROOT / "config.json").exists()) + self.assertIn( + "config.json", + (PLUGIN_ROOT / ".gitignore").read_text(encoding="utf-8"), + ) + + def test_runtime_prompt_injects_full_harness_contract(self): + source = ( + PLUGIN_ROOT + / "extensions" + / "python" + / "message_loop_prompts_after" + / "_20_harness_runtime.py" + ).read_text(encoding="utf-8") + + self.assertIn("render_system_prompt", source) + self.assertNotIn("render_runtime_summary(run)", source) + + def test_dashboard_has_no_runtime_cdn_dependency(self): + dashboard = (PLUGIN_ROOT / "webui" / "dashboard.html").read_text( + encoding="utf-8" + ) + + self.assertNotIn("cdn.jsdelivr.net", dashboard) + self.assertIn("overflow-wrap: anywhere", dashboard) + + def test_observability_canvas_and_theme_native_entrypoints_are_registered(self): + canvas = (PLUGIN_ROOT / "webui" / "canvas.html").read_text( + encoding="utf-8" + ) + status_control = ( + PLUGIN_ROOT + / "extensions" + / "webui" + / "chat-input-progress-start" + / "agent-harness-status.html" + ).read_text(encoding="utf-8") + surface = ( + PLUGIN_ROOT + / "extensions" + / "webui" + / "surfaces_register" + / "_20_register_agent_harness.js" + ).read_text(encoding="utf-8") + panel = ( + PLUGIN_ROOT + / "extensions" + / "webui" + / "right-canvas-panels" + / "_20_agent_harness_panel.html" + ).read_text(encoding="utf-8") + + self.assertIn("Ultra task graph", canvas) + self.assertIn("Approval gates", canvas) + self.assertIn("overflow-x: hidden", canvas) + self.assertIn(".ahc-sync {", canvas) + self.assertIn("appearance: none", canvas) + self.assertIn("background: transparent", canvas) + self.assertIn("openObservability()", status_control) + self.assertIn("agent-harness-toolbar-button", status_control) + self.assertNotIn("btn btn-secondary", status_control) + self.assertNotIn("openModal(", status_control) + self.assertIn('id: "agent-harness"', surface) + self.assertIn('data-surface-id="agent-harness"', panel) + + def test_store_rejects_stale_chat_state(self): + source = (PLUGIN_ROOT / "webui" / "harness-store.js").read_text( + encoding="utf-8" + ) + self.assertIn("_requestSequence", source) + self.assertIn("_inflightContext", source) + self.assertIn("currentContextId() !== contextId", source) + self.assertIn('canvas.open("agent-harness")', source) + + def test_obsolete_manual_setup_scripts_are_removed(self): + self.assertFalse((PLUGIN_ROOT / "Install.md").exists()) + self.assertFalse((PLUGIN_ROOT / "execute.py").exists()) + + def test_readme_matches_current_storage_and_renderer_contracts(self): + readme = (PLUGIN_ROOT / "README.md").read_text(encoding="utf-8") + self.assertNotIn("Project-backed workspaces", readme) + self.assertNotIn("Mermaid graph", readme) + self.assertIn("There is no separate Execute step.", readme) + self.assertIn("Pro and Ultra are intentionally different", readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace.py b/tests/test_workspace.py new file mode 100644 index 0000000..76e7ec7 --- /dev/null +++ b/tests/test_workspace.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +import tempfile +import unittest + +import support # noqa: F401 + +from usr.plugins.agent_harness.helpers.upload_limits import ( + UploadTooLargeError, + read_upload_bytes, +) +from usr.plugins.agent_harness.helpers.workspace import ( + ensure_workspace, + list_uploads, + resolve_artifact, + save_upload, +) + + +class Upload: + def __init__(self, content: bytes, content_length=None): + self.stream = BytesIO(content) + self.content_length = content_length + + def read(self, size=-1): + return self.stream.read(size) + + +class WorkspaceTests(unittest.TestCase): + def test_uploads_are_confined_to_the_thread_directory(self): + with tempfile.TemporaryDirectory() as temp_dir: + paths = ensure_workspace(temp_dir, context_id="ctx") + saved = save_upload(paths, "notes.txt", b"evidence") + + self.assertEqual(Path(saved).read_bytes(), b"evidence") + self.assertEqual(list_uploads(paths)[0]["path"], "notes.txt") + with self.assertRaisesRegex(ValueError, "single safe path segment"): + save_upload(paths, "../escape.txt", b"no") + + def test_symlink_escapes_are_not_listed_or_resolved(self): + with tempfile.TemporaryDirectory() as temp_dir: + paths = ensure_workspace(temp_dir, context_id="ctx") + outside = Path(temp_dir) / "outside.txt" + outside.write_text("private", encoding="utf-8") + upload_link = Path(paths.uploads) / "linked.txt" + upload_link.symlink_to(outside) + artifact_link = Path(paths.outputs) / "linked.txt" + artifact_link.symlink_to(outside) + + self.assertEqual(list_uploads(paths), []) + with self.assertRaisesRegex(ValueError, "escapes"): + resolve_artifact(paths, "linked.txt") + + def test_upload_reader_stops_at_the_limit(self): + with self.assertRaises(UploadTooLargeError): + read_upload_bytes(Upload(b"12345"), limit=4) + + with self.assertRaises(UploadTooLargeError): + read_upload_bytes(Upload(b"x", content_length=5), limit=4) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/harness_checkpoint.py b/tools/harness_checkpoint.py index cf59abe..da9cbe5 100644 --- a/tools/harness_checkpoint.py +++ b/tools/harness_checkpoint.py @@ -18,8 +18,19 @@ async def execute( reason: str = "", proposed_action: str = "", risk_level: str = "high", + target_tool_name: str = "", + target_tool_args: dict | None = None, **kwargs, ) -> Response: + if self.agent.context.get_data( + runtime.PARALLEL_WORKER_CONTEXT_KEY, + recursive=False, + ): + raise RuntimeError( + "Parallel workers cannot request user approvals. Stop this sub-task " + "and report the exact action that the main chat must perform." + ) + settings = runtime.load_agent_settings(self.agent) run = runtime.ensure_run(self.agent, settings=settings) @@ -34,16 +45,30 @@ async def execute( break_loop=False, ) - checkpoint = runtime.request_checkpoint( - run, - reason=str(reason or "Manual checkpoint requested.").strip(), - proposed_action=str(proposed_action or "Await user approval.").strip(), - tool_name=self.name, - tool_args={ + target_name = str(target_tool_name or "").strip() + target_args = dict(target_tool_args or {}) + action = str(proposed_action or "Await user approval.").strip() + if not target_name and ( + runtime.DEPENDENCY_INSTALL_RE.search(action) + or runtime.DESTRUCTIVE_COMMAND_RE.search(action) + or runtime.GIT_MUTATION_COMMAND_RE.search(action) + ): + target_name = "code_execution_tool" + target_args = {"runtime": "terminal", "code": action} + if not target_name: + target_name = self.name + target_args = { "reason": reason, "proposed_action": proposed_action, "risk_level": risk_level, - }, + } + + checkpoint = runtime.request_checkpoint( + run, + reason=str(reason or "Manual checkpoint requested.").strip(), + proposed_action=action, + tool_name=target_name, + tool_args=target_args, risk_level=_coerce_risk_level(risk_level), ) runtime.save_current_run(self.agent.context, run) diff --git a/tools/harness_memory_propose.py b/tools/harness_memory_propose.py index 864c28a..a243cd1 100644 --- a/tools/harness_memory_propose.py +++ b/tools/harness_memory_propose.py @@ -34,6 +34,10 @@ async def execute( settings = runtime.load_agent_settings(self.agent) run = runtime.ensure_run(self.agent, settings=settings) normalized_scope = _coerce_scope(scope) + project_name = projects.get_context_project_name(self.agent.context) or "" + agent_profile = self.agent.config.profile or "" + if normalized_scope == "project" and not project_name: + normalized_scope = "agent" if agent_profile else "global" for existing in run.memory_candidates: if existing.rule_text.strip().lower() != text.lower(): @@ -51,13 +55,11 @@ async def execute( reason=explanation or "Reusable harness rule identified.", source=str(source or runtime.PLUGIN_NAME).strip(), scope=normalized_scope, - confidence=float(confidence), + confidence=_coerce_confidence(confidence), ) runtime.save_current_run(self.agent.context, run) if not settings.get("memory_curation_enabled", True): - project_name = projects.get_context_project_name(self.agent.context) or "" - agent_profile = self.agent.config.profile or "" accepted = await runtime.accept_memory_candidate( context=self.agent.context, candidate_id=candidate.id, @@ -74,3 +76,13 @@ async def execute( message=f"Memory proposal queued for review: {candidate.rule_text}", break_loop=False, ) + + +def _coerce_confidence(value: float) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + parsed = 0.7 + if parsed != parsed: + parsed = 0.7 + return min(1.0, max(0.0, parsed)) diff --git a/tools/harness_run.py b/tools/harness_run.py index c5d1cff..bdd9d51 100644 --- a/tools/harness_run.py +++ b/tools/harness_run.py @@ -4,6 +4,20 @@ from usr.plugins.agent_harness.helpers import runtime +VALID_PHASES = { + "idle", + "inspect", + "plan", + "implement", + "verify", + "repair", + "blocked", + "summarize", + "complete", +} +VALID_VERIFICATION_STATUSES = {"passed", "failed", "unknown"} +VALID_TASK_STATUSES = {"active", "completed", "failed", "blocked"} + def _response(message: str) -> Response: return Response(message=message, break_loop=False) @@ -16,6 +30,13 @@ async def execute(self, action: str = "status", **kwargs) -> Response: run = runtime.get_current_run(self.agent) if action == "start": + if run: + try: + from usr.plugins.agent_harness.helpers.parallel import kill_all + + kill_all(run.run_id) + except ImportError: + pass mode = str( kwargs.get("mode", settings.get("default_deep_mode", runtime.DEFAULT_DEEP_MODE)) ).strip().lower() @@ -33,20 +54,49 @@ async def execute(self, action: str = "status", **kwargs) -> Response: if not run: return _response("No active harness run is available.") + if run.status == "completed" and action != "status": + return _response( + "This harness run is already complete. Start a new run before changing it." + ) + + if run.status == "blocked" and action not in {"status", "failure"}: + pending = runtime.get_pending_checkpoint(run) + detail = f" Pending checkpoint: {pending.reason}" if pending else "" + return _response( + "This harness run is blocked and cannot advance until the user resolves it." + + detail + ) + if action == "phase": phase = str(kwargs.get("phase", "")).strip().lower() - if phase: - run.phase = phase # type: ignore[assignment] - if run.status != "blocked": - run.status = "active" + if phase not in VALID_PHASES: + return _response( + "Invalid phase. Use one of: " + ", ".join(sorted(VALID_PHASES)) + "." + ) + if ( + run.mode == "ultra" + and phase in {"implement", "verify", "summarize", "complete"} + and not run.task_graph + ): + return _response( + "Ultra mode requires a task graph before implementation. " + 'Move to phase="plan", then submit action="plan".' + ) + run.phase = phase # type: ignore[assignment] + run.status = "active" runtime.save_current_run(self.agent.context, run) return _response(f"Harness phase updated to {run.phase}.") if action == "plan": + if run.mode != "ultra": + return _response( + "Task graphs are exclusive to Ultra mode. In this mode, outline " + "the plan in your reasoning and move to phase=\"implement\"." + ) from usr.plugins.agent_harness.helpers.planner import submit_plan sub_tasks = kwargs.get("sub_tasks", []) - if not isinstance(sub_tasks, list): - return _response("plan action requires a 'sub_tasks' list.") + if not isinstance(sub_tasks, list) or not sub_tasks: + return _response("plan action requires a non-empty 'sub_tasks' list.") try: graph = submit_plan(run, sub_tasks) except ValueError as exc: @@ -57,6 +107,11 @@ async def execute(self, action: str = "status", **kwargs) -> Response: return _response(f"Plan accepted with {len(titles)} tasks: {', '.join(titles)}") if action == "dispatch": + if run.mode != "ultra": + return _response( + "Background dispatch is exclusive to Ultra mode. Continue in the " + "main agent or start a new Ultra run." + ) from usr.plugins.agent_harness.helpers.orchestrator import dispatch_ready_tasks from usr.plugins.agent_harness.helpers.parallel import ( spawn_parallel, @@ -68,8 +123,18 @@ async def execute(self, action: str = "status", **kwargs) -> Response: dispatched = dispatch_ready_tasks(run, settings) if not dispatched: if run.task_graph and run.task_graph.is_complete(): - run.phase = "verify" + failed = [ + task + for task in run.task_graph.sub_tasks + if task.status == "failed" + ] + run.phase = "repair" if failed else "verify" runtime.save_current_run(self.agent.context, run) + if failed: + return _response( + f"{len(failed)} sub-task(s) need main-chat repair before " + "verification. Complete them and use action=\"adopt\"." + ) return _response("All sub-tasks complete. Moving to verification phase.") in_flight = active_count(run.run_id) if in_flight > 0: @@ -112,6 +177,10 @@ async def execute(self, action: str = "status", **kwargs) -> Response: ) if action == "collect": + if run.mode != "ultra": + return _response( + "Background collection is exclusive to Ultra mode." + ) from usr.plugins.agent_harness.helpers.parallel import ( poll_status, collect_completed, active_count, reconcile_run_graph, ) @@ -132,9 +201,19 @@ async def execute(self, action: str = "status", **kwargs) -> Response: runtime.save_current_run(self.agent.context, run) if run.task_graph and run.task_graph.is_complete(): - run.phase = "verify" + failed = [ + task for task in run.task_graph.sub_tasks if task.status == "failed" + ] + run.phase = "repair" if failed else "verify" runtime.save_current_run(self.agent.context, run) completed_count = len(results) + if failed: + failed_names = ", ".join(task.title for task in failed[:5]) + return _response( + f"Collected {completed_count} result(s). " + f"{len(failed)} sub-task(s) need main-chat repair: {failed_names}. " + 'Complete each one and use harness_run action="adopt" before verification.' + ) return _response( f"Collected {completed_count} result(s). All sub-tasks complete. " f"Moving to verification phase." @@ -169,13 +248,23 @@ async def execute(self, action: str = "status", **kwargs) -> Response: if action == "task": title = str(kwargs.get("task_title", "")).strip() or "Harness task" - status = str(kwargs.get("task_status", "active")).strip() or "active" + status = ( + str(kwargs.get("task_status", "active")).strip().lower() or "active" + ) + if status not in VALID_TASK_STATUSES: + return _response( + "Invalid task status. Use one of: " + + ", ".join(sorted(VALID_TASK_STATUSES)) + + "." + ) details = str(kwargs.get("task_details", "")).strip() runtime.upsert_task(run, title=title, status=status, details=details) runtime.save_current_run(self.agent.context, run) return _response(f"Tracked harness task: {title} ({status}).") if action == "adopt": + if run.mode != "ultra": + return _response("The adopt action is only used by Ultra task graphs.") from usr.plugins.agent_harness.helpers.planner import mark_sub_task_completed sub_task_id = str(kwargs.get("sub_task_id", "")).strip() @@ -203,6 +292,10 @@ async def execute(self, action: str = "status", **kwargs) -> Response: if action == "verification": name = str(kwargs.get("verification_name", "")).strip() or "Verification" status = str(kwargs.get("verification_status", "unknown")).strip().lower() + if status not in VALID_VERIFICATION_STATUSES: + return _response( + "Invalid verification status. Use passed, failed, or unknown." + ) summary = str(kwargs.get("verification_summary", "")).strip() or name runtime.record_verification( run, @@ -227,19 +320,14 @@ async def execute(self, action: str = "status", **kwargs) -> Response: if run.workspace: from usr.plugins.agent_harness.helpers.workspace import clean_workspace clean_workspace(run.workspace) - return _response("Workspace cleaned. Outputs and run logs preserved.") + return _response("Scratch workspace cleaned. Uploads and outputs preserved.") return _response("No workspace to clean.") if action == "complete": - # Refuse to complete if task graph has unfinished work - if run.task_graph and not run.task_graph.is_complete(): - pending = [t for t in run.task_graph.sub_tasks if t.status in ("pending", "dispatched")] - pending_names = ", ".join(t.title for t in pending[:5]) + blocker = runtime.completion_blocker(run) + if blocker: runtime.save_current_run(self.agent.context, run) - return _response( - f"Cannot complete: {len(pending)} task(s) still unfinished: {pending_names}. " - f'Use harness_run action="dispatch" and action="collect" to finish them first.' - ) + return _response(f"Cannot complete: {blocker}") runtime.complete_run(run) runtime.save_current_run(self.agent.context, run) return _response(f"Harness run completed for: {run.objective}") diff --git a/webui/canvas.html b/webui/canvas.html new file mode 100644 index 0000000..00afd7e --- /dev/null +++ b/webui/canvas.html @@ -0,0 +1,692 @@ + + + + +
+
+
+ +
+
Agent Harness
+
Task graph · gates · verification
+
+
+ +
+ +
+
+ + +
+ +
+
+

+ + Active run +

+ Idle +
+ + + + +
+ +
+
+

+ + Approval gates +

+ +
+
+ +
+
+ +
+
+

+ + Ultra task graph +

+ +
+
+ +
+
+ +
+
+

+ + Verification +

+ +
+ +
+ +
+
+ +
+
+

+ + Memory proposals +

+ +
+
+ +
+
+
+
diff --git a/webui/config.html b/webui/config.html index 0b2ceeb..0a38dd1 100644 --- a/webui/config.html +++ b/webui/config.html @@ -4,172 +4,293 @@
diff --git a/webui/dashboard.html b/webui/dashboard.html index 7635af9..4d9ab40 100644 --- a/webui/dashboard.html +++ b/webui/dashboard.html @@ -5,53 +5,6 @@ -
@@ -66,11 +19,19 @@

Coding-first orchestration for Agent Zero

Drive deep coding workflows, surface checkpoints, and curate reusable rules without leaving the chat.

+
+ Pro structured single agent + Ultra task graph + 1-4 workers +
- - + +
@@ -98,6 +59,16 @@

Active Run

+ +