已合并
harness编排器用户提示优化,提供安装agent配置功能 #14
sunday创建于 9月15日
harness编排器用户提示优化,提供安装agent配置功能 #14
已合并
共 4 个文件变更+324-7
| @@ -7,6 +7,7 @@ disable-model-invocation: true | |||
| 7 | # Workflow Orchestrator | 7 | # Workflow Orchestrator |
| 8 | 8 | ||
| 9 | `$SKILL_ROOT` = the absolute path of the directory containing this SKILL.md. | 9 | `$SKILL_ROOT` = the absolute path of the directory containing this SKILL.md. |
| 10 | +`$START_DIR` = the absolute startup directory of the current Agent session, recorded when this skill begins. It is the plugin project directory containing the model-adapter files. | ||
| 10 | 11 | ||
| 11 | ## Inputs | 12 | ## Inputs |
| 12 | 13 | ||
| @@ -17,13 +18,37 @@ Four required inputs: | |||
| 17 | - `provider` — the Coding Agent CLI used to execute the workflow's tasks. | 18 | - `provider` — the Coding Agent CLI used to execute the workflow's tasks. |
| 18 | - `prompt` — the prompt for this development task. | 19 | - `prompt` — the prompt for this development task. |
| 19 | 20 | ||
| 21 | +For a missing `work_dir`, suggest `$START_DIR/<task_or_project_name_in_snake_case>`. Use the task or project identifier from the prompt, convert word boundaries and separators to underscores, and use lowercase. Ask for an identifier only when needed to derive this directory. Preserve a user-supplied `work_dir`. | ||
| 22 | + | ||
| 20 | ## Steps | 23 | ## Steps |
| 21 | 24 | ||
| 22 | -1. **Collect inputs.** For any of the four that is missing, immediately call the question tool to ask the user; never guess or invent a default. | 25 | +For questions, use the environment's question tool when available; otherwise ask in chat and wait for the reply. |
| 23 | - Done when: all four are in hand. | 26 | + |
| 24 | -2. **Absolutize paths.** Resolve `workflow.yaml` and `work_dir` with `realpath -m` (`-m` because `work_dir` may not exist yet). | 27 | +1. **Prepare input candidates.** First read the orchestrator's CLI help: |
| 25 | - Done when: both are absolute. | 28 | + |
| 26 | -3. **Run the orchestrator** (blackbox — see below) in a detached tmux session, logging output to `<work_dir>/orchestrator.log` with the exit status appended. | 29 | + ```bash |
| 30 | + python3 "$SKILL_ROOT/scripts/orchestrator.py" --help | ||
| 31 | + ``` | ||
| 32 | + | ||
| 33 | + Use the values shown for `--provider` as the source of truth for provider options and validation. Proceed when the command succeeds and lists the accepted values. Preserve valid values the user has supplied. For missing inputs, propose values supported by the current task and session: | ||
| 34 | + - `workflow.yaml`: use the workflow file identified by the user or current plugin workflow; ask if no file is identified. | ||
| 35 | + - `work_dir`: use the task or project directory suggestion under Inputs. | ||
| 36 | + - `provider`: propose the actual CLI command name running the current Agent session when it appears in the help's accepted values. Match the current CLI itself rather than a protocol-compatible alternative. When the current session was started with CANNBot, use `cannbot` even though its agent protocol is compatible with OpenCode. Use session context to identify it; installed executables and configuration directories alone do not identify the running CLI. If the CLI is unknown or unsupported, ask the user to choose from the accepted values. | ||
| 37 | + - `prompt`: use the user's development request, preserving requirements and acceptance criteria; ask for any missing task requirements. | ||
| 38 | + Ask the user for unresolved values. Proposed values remain candidates until confirmed. | ||
| 39 | + Done when: all four concrete candidates are available and `provider` is accepted by the CLI help. | ||
| 40 | +2. **Absolutize paths.** Run `realpath -m` on `workflow.yaml`, `work_dir`, and `$START_DIR` (`-m` allows the proposed `work_dir` to be absent). | ||
| 41 | + Done when: the three resulting paths are absolute and retain their intended locations. | ||
| 42 | +3. **Confirm all four inputs.** Use a single question-tool call containing exactly four questions, one for each input. Populate the provider options from the CLI help output collected in Step 1, and validate any edited provider against that list. In each question body, display the complete current candidate value, including full paths and prompt text. For every option, use the actual value or a meaningful short form as the `label`; put the full value and its explanation in the `description`. Never use a generic label such as "Use this path" that conceals the value. Allow the user to edit each value directly. If a value is missing or invalid, ask only about that value and wait for the user's response. Reuse confirmations already given for these exact values during this run. Suggestions, preselected choices, and silence do not count as confirmation. | ||
| 43 | +4. **Prepare Agent configuration.** Run the linker and require exit status 0 before starting tmux: | ||
| 44 | + | ||
| 45 | + ```bash | ||
| 46 | + python3 "$SKILL_ROOT/scripts/link_agent_config.py" \ | ||
| 47 | + --start-dir "<start_dir>" \ | ||
| 48 | + --work-dir "<work_dir>" >> "<work_dir>/orchestrator.log" 2>&1 | ||
| 49 | + ``` | ||
| 50 | + Done when: `work_dir` exists, the linker exits 0, and its redirected output is present in `<work_dir>/orchestrator.log`. | ||
| 51 | +5. **Run the orchestrator** (blackbox — see below) in a detached tmux session, logging output to `<work_dir>/orchestrator.log` with the exit status appended. | ||
| 27 | 52 | ||
| 28 | Session name `<session>`: `orchestrator-<slug>`, where `<slug>` is the basename of `work_dir` with every character outside `[A-Za-z0-9_-]` replaced by `-`. If `tmux has-session -t <session>` says the name is taken, append `-2`, `-3`, … until it is free. | 53 | Session name `<session>`: `orchestrator-<slug>`, where `<slug>` is the basename of `work_dir` with every character outside `[A-Za-z0-9_-]` replaced by `-`. If `tmux has-session -t <session>` says the name is taken, append `-2`, `-3`, … until it is free. |
| 29 | 54 | ||
| @@ -33,7 +58,7 @@ Four required inputs: | |||
| 33 | --yaml "<workflow.yaml>" \ | 58 | --yaml "<workflow.yaml>" \ |
| 34 | --work-dir "<work_dir>" \ | 59 | --work-dir "<work_dir>" \ |
| 35 | --provider "<provider>" \ | 60 | --provider "<provider>" \ |
| 36 | - --prompt "<prompt>" > "<work_dir>/orchestrator.log" 2>&1; \ | 61 | + --prompt "<prompt>" >> "<work_dir>/orchestrator.log" 2>&1; \ |
| 37 | echo "exit status: $?" >> "<work_dir>/orchestrator.log"' | 62 | echo "exit status: $?" >> "<work_dir>/orchestrator.log"' |
| 38 | ``` | 63 | ``` |
| 39 | 64 | ||
| @@ -44,4 +69,4 @@ Four required inputs: | |||
| 44 | 69 | ||
| 45 | ## Blackbox rule | 70 | ## Blackbox rule |
| 46 | 71 | ||
| 47 | -orchestrator.py is a blackbox: invoke it only through the command line above and judge it only by its output and exit code. Never read or edit its source. | 72 | +orchestrator.py is a blackbox: invoke it with `--help` to discover accepted providers or with the launch command above to run the workflow. Judge it only by its output and exit code. Never read or edit its source. |
| @@ -0,0 +1,89 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 3 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 4 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 5 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 6 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 7 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 8 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 9 | + | ||
| 10 | +"""Expose the current Agent's project configuration in a workflow directory.""" | ||
| 11 | + | ||
| 12 | +import argparse | ||
| 13 | +import logging | ||
| 14 | +import os | ||
| 15 | +from pathlib import Path | ||
| 16 | +import sys | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +# Keep this list aligned with script/bin/cannbot.js TOOL_DIRECTORIES and its | ||
| 20 | +# instruction files. Missing entries are normal for tools not used by a task. | ||
| 21 | +CONFIG_ENTRIES = ( | ||
| 22 | + ".opencode", | ||
| 23 | + ".claude", | ||
| 24 | + ".codex", | ||
| 25 | + ".agents", | ||
| 26 | + ".traecli", | ||
| 27 | + ".marscode", | ||
| 28 | + ".trae", | ||
| 29 | + ".trae-cn", | ||
| 30 | + ".dsh", | ||
| 31 | + "AGENTS.md", | ||
| 32 | + "CLAUDE.md", | ||
| 33 | +) | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +def link_agent_config(start_dir: Path, work_dir: Path) -> list[str]: | ||
| 37 | + """Link existing supported entries, returning the entries created.""" | ||
| 38 | + if not start_dir.is_dir(): | ||
| 39 | + raise ValueError(f"start directory is not a directory: {start_dir}") | ||
| 40 | + work_dir.mkdir(parents=True, exist_ok=True) | ||
| 41 | + if not work_dir.is_dir(): | ||
| 42 | + raise ValueError(f"work directory is not a directory: {work_dir}") | ||
| 43 | + created = [] | ||
| 44 | + for name in CONFIG_ENTRIES: | ||
| 45 | + source = start_dir / name | ||
| 46 | + destination = work_dir / name | ||
| 47 | + # lexists keeps dangling destination links intact. This also makes | ||
| 48 | + # reruns idempotent without replacing local files or directories. | ||
| 49 | + if os.path.lexists(destination) or not os.path.lexists(source): | ||
| 50 | + continue | ||
| 51 | + destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 52 | + destination.symlink_to(source) | ||
| 53 | + created.append(name) | ||
| 54 | + return created | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def main() -> int: | ||
| 58 | + parser = argparse.ArgumentParser(description=__doc__) | ||
| 59 | + parser.add_argument("--start-dir", required=True, help="Agent session startup directory") | ||
| 60 | + parser.add_argument("--work-dir", required=True, help="Workflow working directory") | ||
| 61 | + args = parser.parse_args() | ||
| 62 | + stdout_handler = logging.StreamHandler(sys.stdout) | ||
| 63 | + stdout_handler.addFilter(lambda record: record.levelno < logging.ERROR) | ||
| 64 | + stderr_handler = logging.StreamHandler(sys.stderr) | ||
| 65 | + stderr_handler.setLevel(logging.ERROR) | ||
| 66 | + logging.basicConfig( | ||
| 67 | + level=logging.INFO, | ||
| 68 | + format="[link-agent-config] %(message)s", | ||
| 69 | + handlers=[stdout_handler, stderr_handler], | ||
| 70 | + ) | ||
| 71 | + start_dir = Path(args.start_dir).expanduser().absolute() | ||
| 72 | + work_dir = Path(args.work_dir).expanduser().absolute() | ||
| 73 | + logging.info("start-dir: %s", start_dir) | ||
| 74 | + logging.info("work-dir: %s", work_dir) | ||
| 75 | + try: | ||
| 76 | + created = link_agent_config(start_dir, work_dir) | ||
| 77 | + except (OSError, ValueError) as error: | ||
| 78 | + logging.error("error: %s", error) | ||
| 79 | + return 1 | ||
| 80 | + if created: | ||
| 81 | + result = f"linked {', '.join(created)}" | ||
| 82 | + else: | ||
| 83 | + result = "no new configuration entries" | ||
| 84 | + logging.info("%s", result) | ||
| 85 | + return 0 | ||
| 86 | + | ||
| 87 | + | ||
| 88 | +if __name__ == "__main__": | ||
| 89 | + sys.exit(main()) | ||
| @@ -0,0 +1,202 @@ | |||
| 1 | +# Copyright (c) 2026 Huawei Technologies Co., Ltd. | ||
| 2 | +# This program is free software, you can redistribute it and/or modify it under the terms and conditions of | ||
| 3 | +# CANN Open Software License Agreement Version 2.0 (the "License"). | ||
| 4 | +# Please refer to the License for details. You may not use this file except in compliance with the License. | ||
| 5 | +# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, | ||
| 6 | +# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. | ||
| 7 | +# See LICENSE in the root of the software repository for the full text of the License. | ||
| 8 | + | ||
| 9 | +"""Exercise project configuration linking through its public CLI.""" | ||
| 10 | + | ||
| 11 | +import os | ||
| 12 | +from pathlib import Path | ||
| 13 | +import subprocess | ||
| 14 | +import sys | ||
| 15 | +import tempfile | ||
| 16 | +import unittest | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "link_agent_config.py" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +class LinkAgentConfigTest(unittest.TestCase): | ||
| 23 | + def setUp(self): | ||
| 24 | + temporary = tempfile.TemporaryDirectory(prefix="agent-config-test-") | ||
| 25 | + self.addCleanup(temporary.cleanup) | ||
| 26 | + self.root = Path(temporary.name) | ||
| 27 | + self.start_dir = self.root / "agent startup" | ||
| 28 | + self.start_dir.mkdir() | ||
| 29 | + self.work_dir = self.root / "new work" / "nested" | ||
| 30 | + | ||
| 31 | + def run_linker(self, start_dir=None, work_dir=None, cwd=None): | ||
| 32 | + return subprocess.run( | ||
| 33 | + [sys.executable, str(SCRIPT), | ||
| 34 | + "--start-dir", str(self.start_dir if start_dir is None else start_dir), | ||
| 35 | + "--work-dir", str(self.work_dir if work_dir is None else work_dir)], | ||
| 36 | + cwd=cwd, capture_output=True, text=True, | ||
| 37 | + ) | ||
| 38 | + | ||
| 39 | + def assert_success(self, result): | ||
| 40 | + self.assertEqual(result.returncode, 0, (result.stdout or "") + (result.stderr or "")) | ||
| 41 | + | ||
| 42 | + def test_links_installer_configuration_and_instructions(self): | ||
| 43 | + directories = ( | ||
| 44 | + ".opencode", ".claude", ".codex", ".agents", ".traecli", | ||
| 45 | + ".marscode", ".trae", ".trae-cn", ".dsh", | ||
| 46 | + ) | ||
| 47 | + for name in directories: | ||
| 48 | + (self.start_dir / name).mkdir() | ||
| 49 | + (self.start_dir / name / "config.txt").write_text(name) | ||
| 50 | + for name in ("AGENTS.md", "CLAUDE.md"): | ||
| 51 | + (self.start_dir / name).write_text(name) | ||
| 52 | + for name in (".git", ".workflow", "node_modules"): | ||
| 53 | + (self.start_dir / name).mkdir() | ||
| 54 | + (self.start_dir / ".env").write_text("local settings") | ||
| 55 | + | ||
| 56 | + self.assert_success(self.run_linker()) | ||
| 57 | + | ||
| 58 | + self.assertEqual( | ||
| 59 | + {path.name for path in self.work_dir.iterdir()}, | ||
| 60 | + set(directories) | {"AGENTS.md", "CLAUDE.md"}, | ||
| 61 | + ) | ||
| 62 | + for name in directories + ("AGENTS.md", "CLAUDE.md"): | ||
| 63 | + with self.subTest(name=name): | ||
| 64 | + target = self.work_dir / name | ||
| 65 | + self.assertTrue(target.is_symlink()) | ||
| 66 | + self.assertEqual(os.readlink(target), str(self.start_dir / name)) | ||
| 67 | + content = target / "config.txt" if name in directories else target | ||
| 68 | + self.assertEqual(content.read_text(), name) | ||
| 69 | + | ||
| 70 | + def test_repeated_run_preserves_link_and_adds_missing_entries(self): | ||
| 71 | + (self.start_dir / ".opencode").mkdir() | ||
| 72 | + self.assert_success(self.run_linker()) | ||
| 73 | + target = self.work_dir / ".opencode" | ||
| 74 | + original = target.lstat() | ||
| 75 | + (self.start_dir / ".claude").mkdir() | ||
| 76 | + | ||
| 77 | + self.assert_success(self.run_linker()) | ||
| 78 | + | ||
| 79 | + self.assertEqual(target.lstat().st_ino, original.st_ino) | ||
| 80 | + self.assertEqual(target.lstat().st_mtime_ns, original.st_mtime_ns) | ||
| 81 | + self.assertTrue((self.work_dir / ".claude").is_symlink()) | ||
| 82 | + | ||
| 83 | + def test_preserves_existing_links_even_when_dangling_or_pointing_elsewhere(self): | ||
| 84 | + self.work_dir.mkdir(parents=True) | ||
| 85 | + other = self.root / "other config" | ||
| 86 | + other.mkdir() | ||
| 87 | + for name, destination in ((".opencode", other), | ||
| 88 | + (".claude", self.root / "missing config")): | ||
| 89 | + (self.start_dir / name).mkdir() | ||
| 90 | + (self.work_dir / name).symlink_to(destination) | ||
| 91 | + (self.start_dir / ".codex").mkdir() | ||
| 92 | + | ||
| 93 | + self.assert_success(self.run_linker()) | ||
| 94 | + | ||
| 95 | + self.assertEqual(os.readlink(self.work_dir / ".opencode"), str(other)) | ||
| 96 | + self.assertEqual(os.readlink(self.work_dir / ".claude"), | ||
| 97 | + str(self.root / "missing config")) | ||
| 98 | + self.assertTrue((self.work_dir / ".codex").is_symlink()) | ||
| 99 | + | ||
| 100 | + def test_preserves_existing_real_directories_and_files(self): | ||
| 101 | + self.work_dir.mkdir(parents=True) | ||
| 102 | + (self.start_dir / ".claude").mkdir() | ||
| 103 | + (self.start_dir / "AGENTS.md").write_text("source instructions") | ||
| 104 | + (self.work_dir / ".claude").mkdir() | ||
| 105 | + (self.work_dir / ".claude" / "keep.txt").write_text("local config") | ||
| 106 | + (self.work_dir / "AGENTS.md").write_text("local instructions") | ||
| 107 | + | ||
| 108 | + self.assert_success(self.run_linker()) | ||
| 109 | + | ||
| 110 | + self.assertFalse((self.work_dir / ".claude").is_symlink()) | ||
| 111 | + self.assertEqual((self.work_dir / ".claude" / "keep.txt").read_text(), | ||
| 112 | + "local config") | ||
| 113 | + self.assertEqual((self.work_dir / "AGENTS.md").read_text(), | ||
| 114 | + "local instructions") | ||
| 115 | + | ||
| 116 | + def test_missing_configuration_still_creates_work_directory(self): | ||
| 117 | + self.assert_success(self.run_linker()) | ||
| 118 | + self.assertTrue(self.work_dir.is_dir()) | ||
| 119 | + self.assertEqual(list(self.work_dir.iterdir()), []) | ||
| 120 | + | ||
| 121 | + def test_prints_arguments_and_linking_result_without_writing_a_log(self): | ||
| 122 | + (self.start_dir / ".codex").mkdir() | ||
| 123 | + | ||
| 124 | + result = self.run_linker() | ||
| 125 | + self.assert_success(result) | ||
| 126 | + | ||
| 127 | + self.assertIn(f"start-dir: {self.start_dir}", result.stdout) | ||
| 128 | + self.assertIn(f"work-dir: {self.work_dir}", result.stdout) | ||
| 129 | + self.assertIn("linked .codex", result.stdout) | ||
| 130 | + self.assertFalse((self.work_dir / "orchestrator.log").exists()) | ||
| 131 | + | ||
| 132 | + def test_skill_style_stdout_redirect_appends_to_orchestrator_log(self): | ||
| 133 | + self.work_dir.mkdir(parents=True) | ||
| 134 | + log_path = self.work_dir / "orchestrator.log" | ||
| 135 | + log_path.write_text("existing workflow output\n") | ||
| 136 | + | ||
| 137 | + with log_path.open("a", encoding="utf-8") as log: | ||
| 138 | + result = subprocess.run( | ||
| 139 | + [sys.executable, str(SCRIPT), "--start-dir", str(self.start_dir), | ||
| 140 | + "--work-dir", str(self.work_dir)], | ||
| 141 | + stdout=log, stderr=subprocess.PIPE, text=True, | ||
| 142 | + ) | ||
| 143 | + self.assert_success(result) | ||
| 144 | + | ||
| 145 | + log = log_path.read_text() | ||
| 146 | + self.assertTrue(log.startswith("existing workflow output\n")) | ||
| 147 | + self.assertIn(f"start-dir: {self.start_dir}", log) | ||
| 148 | + | ||
| 149 | + def test_relative_paths_link_to_explicit_start_directory(self): | ||
| 150 | + (self.start_dir / ".codex").mkdir() | ||
| 151 | + (self.root / ".claude").mkdir() | ||
| 152 | + | ||
| 153 | + self.assert_success(self.run_linker( | ||
| 154 | + start_dir="agent startup", work_dir="new work/nested", cwd=self.root, | ||
| 155 | + )) | ||
| 156 | + | ||
| 157 | + self.assertEqual(os.readlink(self.work_dir / ".codex"), | ||
| 158 | + str(self.start_dir / ".codex")) | ||
| 159 | + self.assertFalse((self.work_dir / ".claude").exists()) | ||
| 160 | + | ||
| 161 | + def test_source_symlink_retains_indirection(self): | ||
| 162 | + original = self.root / "shared config" | ||
| 163 | + original.mkdir() | ||
| 164 | + (self.start_dir / ".agents").symlink_to(original) | ||
| 165 | + | ||
| 166 | + self.assert_success(self.run_linker()) | ||
| 167 | + | ||
| 168 | + target = self.work_dir / ".agents" | ||
| 169 | + self.assertEqual(os.readlink(target), str(self.start_dir / ".agents")) | ||
| 170 | + self.assertEqual(target.resolve(), original) | ||
| 171 | + | ||
| 172 | + def test_same_start_and_work_directory_preserves_source(self): | ||
| 173 | + (self.start_dir / ".claude").mkdir() | ||
| 174 | + | ||
| 175 | + self.assert_success(self.run_linker(work_dir=self.start_dir)) | ||
| 176 | + | ||
| 177 | + self.assertTrue((self.start_dir / ".claude").is_dir()) | ||
| 178 | + self.assertFalse((self.start_dir / ".claude").is_symlink()) | ||
| 179 | + | ||
| 180 | + def test_invalid_start_directory_fails_without_creating_work_directory(self): | ||
| 181 | + source_file = self.root / "source file" | ||
| 182 | + source_file.write_text("keep") | ||
| 183 | + for source in (self.root / "missing source", source_file): | ||
| 184 | + with self.subTest(source=source): | ||
| 185 | + result = self.run_linker(start_dir=source) | ||
| 186 | + self.assertNotEqual(result.returncode, 0) | ||
| 187 | + self.assertIn("start directory", result.stderr.lower()) | ||
| 188 | + self.assertFalse(self.work_dir.exists()) | ||
| 189 | + | ||
| 190 | + def test_work_directory_conflicting_with_file_reports_failure(self): | ||
| 191 | + self.work_dir.parent.mkdir() | ||
| 192 | + self.work_dir.write_text("keep") | ||
| 193 | + | ||
| 194 | + result = self.run_linker() | ||
| 195 | + | ||
| 196 | + self.assertNotEqual(result.returncode, 0) | ||
| 197 | + self.assertEqual(self.work_dir.read_text(), "keep") | ||
| 198 | + self.assertIn("error", result.stderr.lower()) | ||
| 199 | + | ||
| 200 | + | ||
| 201 | +if __name__ == "__main__": | ||
| 202 | + unittest.main() | ||