feat(tester): initialize agent target from Orca selectors
This commit is contained in:
@@ -37,6 +37,18 @@ EXPECTED_KINDS = (
|
||||
"url",
|
||||
"interaction",
|
||||
)
|
||||
AGENT_DEFAULTS = {
|
||||
"environment": "",
|
||||
"host": "local",
|
||||
"project": "",
|
||||
"path": "",
|
||||
"role": "test",
|
||||
"cli": "codex",
|
||||
"tier": "standard",
|
||||
"model": None,
|
||||
"reasoningEffort": "medium",
|
||||
"permissionMode": "workspace-write",
|
||||
}
|
||||
|
||||
|
||||
def catalog_dir(root: Path) -> Path:
|
||||
@@ -84,6 +96,41 @@ def load_local(root: Path) -> dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def resolve_agent(root: Path, meta: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve the structured agent target used by one tester run.
|
||||
|
||||
Stable defaults may live in catalog.yaml. Machine-specific values belong in
|
||||
local.yaml and override the mapping shallowly. The old execution mapping is
|
||||
accepted as a compatibility input and is projected onto the agent target.
|
||||
"""
|
||||
agent: dict[str, Any] = dict(AGENT_DEFAULTS)
|
||||
local = load_local(root)
|
||||
found_agent = False
|
||||
for source_name, source in (
|
||||
("catalog.agent", meta.get("agent")),
|
||||
("local.agent", local.get("agent")),
|
||||
):
|
||||
if source is None:
|
||||
continue
|
||||
found_agent = True
|
||||
if not isinstance(source, dict):
|
||||
raise SystemExit(f"{source_name} 必须是 mapping")
|
||||
agent.update(source)
|
||||
if not found_agent:
|
||||
for source_name, source in (
|
||||
("catalog.execution", meta.get("execution")),
|
||||
("local.execution", local.get("execution")),
|
||||
):
|
||||
if source is None:
|
||||
continue
|
||||
if not isinstance(source, dict):
|
||||
raise SystemExit(f"{source_name} 必须是 mapping")
|
||||
for key in ("environment", "host", "path"):
|
||||
if key in source:
|
||||
agent[key] = source[key]
|
||||
return agent
|
||||
|
||||
|
||||
def case_files(root: Path) -> list[Path]:
|
||||
d = catalog_dir(root) / "cases"
|
||||
if not d.is_dir():
|
||||
@@ -143,7 +190,7 @@ def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]:
|
||||
merged[str(key)] = "" if value is None else str(value)
|
||||
local = load_local(root)
|
||||
for key, value in local.items():
|
||||
if value is None:
|
||||
if value is None or isinstance(value, (dict, list)):
|
||||
continue
|
||||
merged[str(key)] = str(value).lower() if isinstance(value, bool) else str(value)
|
||||
auth_keys = set()
|
||||
@@ -338,6 +385,44 @@ def probe_errors(where: str, probe: Any) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def agent_errors(root: Path, meta: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
try:
|
||||
agent = resolve_agent(root, meta)
|
||||
except SystemExit as exc:
|
||||
return [str(exc)]
|
||||
for key in (
|
||||
"environment",
|
||||
"host",
|
||||
"project",
|
||||
"path",
|
||||
"role",
|
||||
"cli",
|
||||
"tier",
|
||||
"model",
|
||||
"reasoningEffort",
|
||||
"permissionMode",
|
||||
):
|
||||
if key not in agent:
|
||||
errors.append(f"agent.{key} 缺失")
|
||||
for key in ("environment", "host", "project", "path", "role", "cli", "tier", "permissionMode"):
|
||||
if not isinstance(agent.get(key), str):
|
||||
errors.append(f"agent.{key} 必须是字符串")
|
||||
if agent.get("model") is not None and not isinstance(agent.get("model"), str):
|
||||
errors.append("agent.model 必须是字符串或 null")
|
||||
if agent.get("role") != "test":
|
||||
errors.append("agent.role 必须是 test")
|
||||
if agent.get("cli") not in ("codex", "cursor-agent", "grok", "omp", "pi"):
|
||||
errors.append("agent.cli 只能是 codex/cursor-agent/grok/omp/pi")
|
||||
if agent.get("tier") not in ("standard", "strong"):
|
||||
errors.append("agent.tier 只能是 standard 或 strong")
|
||||
if agent.get("reasoningEffort") not in (None, "low", "medium", "high", "xhigh"):
|
||||
errors.append("agent.reasoningEffort 只能是 low/medium/high/xhigh 或 null")
|
||||
if agent.get("permissionMode") not in ("read-only", "workspace-write"):
|
||||
errors.append("agent.permissionMode 只能是 read-only 或 workspace-write")
|
||||
return errors
|
||||
|
||||
|
||||
def run_together_groups(meta: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
raw = meta.get("run_together") or []
|
||||
if not isinstance(raw, list):
|
||||
@@ -505,6 +590,7 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
"skipped": skipped,
|
||||
"run_together": together,
|
||||
"status_probe": status_probes,
|
||||
"agent": resolve_agent(root, meta),
|
||||
"cases": selected,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -533,6 +619,7 @@ def validate(root: Path) -> list[str]:
|
||||
meta = load_catalog_meta(root)
|
||||
except SystemExit as exc:
|
||||
return [str(exc)]
|
||||
errors.extend(agent_errors(root, meta))
|
||||
|
||||
cats = meta.get("categories")
|
||||
if not isinstance(cats, list):
|
||||
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve an Orca project or terminal into tester's structured agent config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("需要 PyYAML: python3 -m pip install pyyaml", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
SUPPORTED_CLI = {"codex", "cursor-agent", "grok", "omp", "pi"}
|
||||
|
||||
|
||||
def run_orca(args: list[str], environment: str | None = None) -> dict[str, Any]:
|
||||
command = ["orca", *args]
|
||||
if environment and environment != "local":
|
||||
command.extend(["--environment", environment])
|
||||
command.append("--json")
|
||||
proc = subprocess.run(command, capture_output=True, text=True, check=False)
|
||||
raw = (proc.stdout or "").strip()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
detail = (proc.stderr or raw or str(exc)).strip()
|
||||
raise RuntimeError(f"orca 命令没有返回 JSON: {' '.join(command)}\n{detail}") from exc
|
||||
if not data.get("ok"):
|
||||
error = data.get("error") or {}
|
||||
message = error.get("message") or error.get("code") or "unknown error"
|
||||
raise RuntimeError(f"{' '.join(command)}: {message}")
|
||||
result = data.get("result")
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def environments() -> list[str]:
|
||||
result = run_orca(["environment", "list"])
|
||||
names = ["local"]
|
||||
for item in result.get("environments") or []:
|
||||
if isinstance(item, dict) and item.get("name"):
|
||||
names.append(str(item["name"]))
|
||||
return names
|
||||
|
||||
|
||||
def worktree_from_terminal(terminal: dict[str, Any], environment: str) -> dict[str, Any]:
|
||||
worktree_id = str(terminal.get("worktreeId") or "")
|
||||
if not worktree_id:
|
||||
raise RuntimeError("terminal 没有 worktreeId")
|
||||
result = run_orca(["worktree", "show", "--worktree", worktree_id], environment)
|
||||
worktree = result.get("worktree")
|
||||
if not isinstance(worktree, dict):
|
||||
raise RuntimeError("找不到 terminal 对应的 worktree")
|
||||
return worktree
|
||||
|
||||
|
||||
def config_from_terminal(handle: str, requested_environment: str | None) -> dict[str, Any]:
|
||||
candidates = [requested_environment] if requested_environment else environments()
|
||||
errors: list[str] = []
|
||||
for environment in candidates:
|
||||
if not environment:
|
||||
continue
|
||||
try:
|
||||
result = run_orca(["terminal", "show", "--terminal", handle], environment)
|
||||
terminal = result.get("terminal")
|
||||
if not isinstance(terminal, dict):
|
||||
raise RuntimeError("返回缺少 terminal")
|
||||
worktree = worktree_from_terminal(terminal, environment)
|
||||
identity = str(terminal.get("agentIdentity") or "")
|
||||
cli = identity if identity in SUPPORTED_CLI else "codex"
|
||||
return {
|
||||
"environment": "" if environment == "local" else environment,
|
||||
"host": str(terminal.get("executionHostId") or worktree.get("hostId") or "local"),
|
||||
"project": str(worktree.get("projectId") or worktree.get("repoId") or ""),
|
||||
"path": str(terminal.get("worktreePath") or worktree.get("path") or ""),
|
||||
"role": "test",
|
||||
"cli": cli,
|
||||
"tier": "standard",
|
||||
"model": None,
|
||||
"reasoningEffort": "medium",
|
||||
"permissionMode": "workspace-write",
|
||||
"_source": f"terminal:{handle}",
|
||||
"_project_name": str(worktree.get("displayName") or ""),
|
||||
"_terminal_agent_identity": identity,
|
||||
}
|
||||
except RuntimeError as exc:
|
||||
errors.append(f"{environment}: {exc}")
|
||||
raise RuntimeError("找不到可用 terminal: " + ";".join(errors))
|
||||
|
||||
|
||||
def worktrees(environment: str) -> list[dict[str, Any]]:
|
||||
result = run_orca(["worktree", "list", "--limit", "200"], environment)
|
||||
return [item for item in result.get("worktrees") or [] if isinstance(item, dict)]
|
||||
|
||||
|
||||
def projects(environment: str) -> list[dict[str, Any]]:
|
||||
result = run_orca(["project", "list"], environment)
|
||||
return [item for item in result.get("projects") or [] if isinstance(item, dict)]
|
||||
|
||||
|
||||
def config_from_project(
|
||||
selector: str,
|
||||
requested_environment: str | None,
|
||||
requested_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
environments_to_check = [requested_environment] if requested_environment else environments()
|
||||
matches: list[tuple[str, dict[str, Any]]] = []
|
||||
for environment in environments_to_check:
|
||||
if not environment:
|
||||
continue
|
||||
project_ids: set[str] = set()
|
||||
for project in projects(environment):
|
||||
project_values = {
|
||||
str(project.get("id") or ""),
|
||||
str(project.get("displayName") or ""),
|
||||
}
|
||||
if selector in project_values:
|
||||
project_ids.add(str(project.get("id") or ""))
|
||||
project_ids.update(str(repo_id) for repo_id in project.get("sourceRepoIds") or [])
|
||||
|
||||
for item in worktrees(environment):
|
||||
values = {
|
||||
str(item.get("id") or ""),
|
||||
str(item.get("projectId") or ""),
|
||||
str(item.get("repoId") or ""),
|
||||
str(item.get("displayName") or ""),
|
||||
str(item.get("path") or ""),
|
||||
Path(str(item.get("path") or "")).name,
|
||||
}
|
||||
direct_match = selector in values
|
||||
project_match = bool(
|
||||
project_ids
|
||||
and project_ids.intersection(
|
||||
{
|
||||
str(item.get("projectId") or ""),
|
||||
str(item.get("repoId") or ""),
|
||||
}
|
||||
)
|
||||
)
|
||||
path_match = not requested_path or str(item.get("path") or "") == requested_path
|
||||
if path_match and (direct_match or project_match):
|
||||
matches.append((environment, item))
|
||||
if not matches:
|
||||
raise RuntimeError(f"找不到 project id/名称: {selector}")
|
||||
if len(matches) > 1:
|
||||
lines = [
|
||||
f"{env}: {item.get('projectId') or item.get('repoId')} "
|
||||
f"{item.get('displayName') or ''} {item.get('path') or ''}"
|
||||
for env, item in matches
|
||||
]
|
||||
raise RuntimeError("project 匹配多个 worktree,请补充 environment 或 path:\n" + "\n".join(lines))
|
||||
environment, item = matches[0]
|
||||
return {
|
||||
"environment": "" if environment == "local" else environment,
|
||||
"host": str(item.get("hostId") or "local"),
|
||||
"project": str(item.get("projectId") or item.get("repoId") or item.get("id") or ""),
|
||||
"path": str(item.get("path") or ""),
|
||||
"role": "test",
|
||||
"cli": "codex",
|
||||
"tier": "standard",
|
||||
"model": None,
|
||||
"reasoningEffort": "medium",
|
||||
"permissionMode": "workspace-write",
|
||||
"_source": f"project:{selector}",
|
||||
"_project_name": str(item.get("displayName") or ""),
|
||||
}
|
||||
|
||||
|
||||
def clean_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: value for key, value in config.items() if not key.startswith("_")}
|
||||
|
||||
|
||||
def write_local(root: Path, config: dict[str, Any]) -> tuple[Path, dict[str, Any]]:
|
||||
path = root / ".pouch" / "tester" / "local.yaml"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data: dict[str, Any] = {}
|
||||
if path.is_file():
|
||||
loaded = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise RuntimeError(f"{path} 必须是 mapping")
|
||||
data = loaded
|
||||
current_agent = data.get("agent")
|
||||
if current_agent is None:
|
||||
current_agent = {}
|
||||
if not isinstance(current_agent, dict):
|
||||
raise RuntimeError(f"{path} 的 agent 必须是 mapping")
|
||||
|
||||
resolved = clean_config(config)
|
||||
# Target location always comes from the user's selector. Keep explicit local
|
||||
# choices for model/policy fields, so resolving a terminal cannot erase them.
|
||||
location_fields = {"environment", "host", "project", "path"}
|
||||
terminal_cli = bool(config.get("_terminal_agent_identity"))
|
||||
merged = dict(current_agent)
|
||||
for key, value in resolved.items():
|
||||
if key in location_fields or key not in merged or (key == "cli" and terminal_cli):
|
||||
merged[key] = value
|
||||
data["agent"] = merged
|
||||
path.write_text(yaml.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
|
||||
return path, merged
|
||||
|
||||
|
||||
def print_confirmation(config: dict[str, Any], written: bool) -> None:
|
||||
model = config.get("model") or "CLI 默认模型"
|
||||
print("## tester Agent 配置更新结果" if written else "## tester Agent 配置解析预览")
|
||||
print()
|
||||
print(f"来源: {config.get('_source', '配置文件')}")
|
||||
print(f"Agent: {config.get('cli')}")
|
||||
print(f"角色 / 档位: {config.get('role')} / {config.get('tier')}")
|
||||
print(f"模型: {model}")
|
||||
print(f"推理强度: {config.get('reasoningEffort') or 'n/a'}")
|
||||
print(f"权限: {config.get('permissionMode') or 'n/a'}")
|
||||
print(f"Orca 环境: {config.get('environment') or 'local'}")
|
||||
print(f"执行节点: {config.get('host') or 'local'}")
|
||||
print(f"项目: {config.get('project') or 'n/a'}({config.get('_project_name') or 'n/a'})")
|
||||
print(f"目录: {config.get('path') or 'n/a'}")
|
||||
print("Terminal: 每轮新建,结束后关闭,不复用输入的 terminal")
|
||||
print()
|
||||
if written:
|
||||
print("以上是已写入 local.yaml.agent 的生效配置;如需调整,直接修改配置后重新运行 tester。")
|
||||
else:
|
||||
print("以上是解析结果预览;加上 --write 后会写入 local.yaml.agent。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="解析 Orca project/terminal 为 tester Agent 配置")
|
||||
parser.add_argument("--root", default=".", help="项目根")
|
||||
source = parser.add_mutually_exclusive_group(required=True)
|
||||
source.add_argument("--terminal", help="Orca terminal handle")
|
||||
source.add_argument("--project", help="Orca project id、名称或路径")
|
||||
parser.add_argument("--path", help="项目有多个 worktree 时按目标机器上的绝对路径消歧")
|
||||
parser.add_argument("--environment", help="限定 Orca environment")
|
||||
parser.add_argument("--write", action="store_true", help="写入 .pouch/tester/local.yaml")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
config = (
|
||||
config_from_terminal(args.terminal, args.environment)
|
||||
if args.terminal
|
||||
else config_from_project(args.project, args.environment, args.path)
|
||||
)
|
||||
if args.write:
|
||||
path, saved_agent = write_local(Path(args.root).resolve(), config)
|
||||
config = {**config, **saved_agent}
|
||||
print(f"已写入: {path}")
|
||||
print_confirmation(config, written=args.write)
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(f"environment-fail: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user