Add SystemTest Agent and Controller
This commit is contained in:
commit
53f523f187
31 files changed
+9150
No files matched your search
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# Python caches and virtual environments
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Local environment and credentials
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.template
|
||||||
|
.agent_bridge_token.json
|
||||||
|
**/.agent_bridge_token.json
|
||||||
|
|
||||||
|
# Runtime state, logs, and generated evidence
|
||||||
|
logs/
|
||||||
|
**/logs/
|
||||||
|
SystemTest-Agent/data/
|
||||||
|
SystemTest-Controller/data/
|
||||||
|
Agent-S-main/Agent-S-main/task_result_and_debug_logs/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# JavaScript build outputs and dependencies
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Python build/test outputs
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
|
||||||
|
# Editor and OS files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local packaging scratch space
|
||||||
|
.push_worktree/
|
||||||
|
.tmp_git_nested_test/
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SystemTest-Agent
|
||||||
|
|
||||||
|
P0-02 被控端最小可用实现,首次启动向主控注册并保存 token,后续启动使用本地 token 发送 Bearer 心跳并轮询任务。
|
||||||
|
|
||||||
|
## 一键启动
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_agent.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
CMD:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
start_agent.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会在局域网内自动发现 `8001/8000` 端口上的主控。也可以手工指定主控地址:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_agent.ps1 -ControllerUrl http://<controller-lan-ip>:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
```bat
|
||||||
|
start_agent.bat http://<controller-lan-ip>:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
如果在另一台局域网设备上双击后窗口闪退,常见原因是:
|
||||||
|
|
||||||
|
- 该设备访问不到主控机的 `8001` 端口,需放行主控机防火墙或手工传入 `http://<主控机局域网IP>:8001`
|
||||||
|
- 该设备没有安装 Python 3,或 `python/py` 不在 PATH
|
||||||
|
- 被控本地服务端口 `18081` 已被占用
|
||||||
|
|
||||||
|
新版 `start_agent.bat` 遇到这些错误会暂停并显示原因。
|
||||||
|
|
||||||
|
默认被控本地服务端口与 `Agent-S-main/bridge_config.yaml` 的 `log_web_port` 保持一致:
|
||||||
|
|
||||||
|
- Local health: `http://127.0.0.1:18081/health`
|
||||||
|
- 环境变量:`AGENT_LOCAL_SERVICE_PORT=18081`
|
||||||
|
|
||||||
|
## 状态持久化
|
||||||
|
|
||||||
|
首次注册成功后写入 `data/agent_state.json`:
|
||||||
|
|
||||||
|
- `agent_id`
|
||||||
|
- `public_id`
|
||||||
|
- `token`
|
||||||
|
- `registered_at`
|
||||||
|
- `controller_url`
|
||||||
|
|
||||||
|
重启时如果该文件存在且机器标识一致,被控端不会重复注册,会直接用 Bearer token 发送心跳和领任务请求。
|
||||||
|
|
||||||
|
## 部署形态
|
||||||
|
|
||||||
|
`AGENT_DEPLOYMENT_TYPE=auto` 时自动判断物理机/虚拟机;也可显式设置:
|
||||||
|
|
||||||
|
- `physical_machine`
|
||||||
|
- `virtual_machine`
|
||||||
|
- `box_physical`
|
||||||
|
- `box_virtual`
|
||||||
|
|
||||||
|
## 已完成的 P0-02 验收点
|
||||||
|
|
||||||
|
- 首次启动自动注册
|
||||||
|
- token 保存到本地状态文件
|
||||||
|
- 后续启动不重复注册
|
||||||
|
- 心跳和领任务请求使用 `Authorization: Bearer <token>`
|
||||||
|
- 注册与心跳上报物理机/虚拟机部署形态
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""System test controlled agent package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .runner import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .memory import Blackboard, MemoryItem
|
||||||
|
from .session import AgentSession
|
||||||
|
from .tools import AgentToolRegistry
|
||||||
|
|
||||||
|
__all__ = ["AgentSession", "AgentToolRegistry", "Blackboard", "MemoryItem"]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MemoryItem:
|
||||||
|
kind: str
|
||||||
|
content: dict[str, Any]
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
task_id: str | None = None
|
||||||
|
step: int | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"kind": self.kind,
|
||||||
|
"content": self.content,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"step": self.step,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, payload: dict[str, Any]) -> "MemoryItem":
|
||||||
|
return cls(
|
||||||
|
kind=str(payload.get("kind") or "event"),
|
||||||
|
content=payload.get("content") if isinstance(payload.get("content"), dict) else {},
|
||||||
|
created_at=str(payload.get("created_at") or utc_now()),
|
||||||
|
task_id=str(payload.get("task_id")) if payload.get("task_id") is not None else None,
|
||||||
|
step=int(payload["step"]) if isinstance(payload.get("step"), int) else None,
|
||||||
|
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Blackboard:
|
||||||
|
"""
|
||||||
|
Lightweight file-backed memory shared by the local Host/App agent layers.
|
||||||
|
|
||||||
|
It intentionally mirrors UFO's blackboard shape without pulling in the full
|
||||||
|
dependency stack: requests, trajectories, observations, screenshots and facts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_KINDS = ("requests", "trajectories", "observations", "screenshots", "facts")
|
||||||
|
|
||||||
|
def __init__(self, path: Path, max_items_per_kind: int = 200):
|
||||||
|
self.path = path
|
||||||
|
self.max_items_per_kind = max_items_per_kind
|
||||||
|
self._content: dict[str, list[MemoryItem]] = {kind: [] for kind in self.DEFAULT_KINDS}
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
if not self.path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return
|
||||||
|
for kind in self.DEFAULT_KINDS:
|
||||||
|
entries = raw.get(kind)
|
||||||
|
if isinstance(entries, list):
|
||||||
|
self._content[kind] = [
|
||||||
|
MemoryItem.from_dict(item)
|
||||||
|
for item in entries
|
||||||
|
if isinstance(item, dict)
|
||||||
|
][-self.max_items_per_kind :]
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {
|
||||||
|
kind: [item.to_dict() for item in items[-self.max_items_per_kind :]]
|
||||||
|
for kind, items in self._content.items()
|
||||||
|
}
|
||||||
|
self.path.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def append(
|
||||||
|
self,
|
||||||
|
kind: str,
|
||||||
|
content: dict[str, Any],
|
||||||
|
*,
|
||||||
|
task_id: str | None = None,
|
||||||
|
step: int | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> MemoryItem:
|
||||||
|
if kind not in self._content:
|
||||||
|
self._content[kind] = []
|
||||||
|
item = MemoryItem(
|
||||||
|
kind=kind,
|
||||||
|
content=content,
|
||||||
|
task_id=task_id,
|
||||||
|
step=step,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
self._content[kind].append(item)
|
||||||
|
self._content[kind] = self._content[kind][-self.max_items_per_kind :]
|
||||||
|
self.save()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def recent(self, kind: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||||
|
return [item.to_dict() for item in self._content.get(kind, [])[-limit:]]
|
||||||
|
|
||||||
|
def snapshot(self, limit: int = 5) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
return {kind: self.recent(kind, limit) for kind in self._content}
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"path": str(self.path),
|
||||||
|
"counts": {kind: len(items) for kind, items in self._content.items()},
|
||||||
|
"latest_trajectory": self.recent("trajectories", 1),
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelResponse:
|
||||||
|
ok: bool
|
||||||
|
content: str = ""
|
||||||
|
parsed: dict[str, Any] | None = None
|
||||||
|
duration_seconds: float = 0.0
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_completions_url(base_url: str) -> str:
|
||||||
|
base = base_url.rstrip("/")
|
||||||
|
if base.endswith("/chat/completions"):
|
||||||
|
return base
|
||||||
|
if base.endswith("/v1"):
|
||||||
|
return f"{base}/chat/completions"
|
||||||
|
return f"{base}/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_json_object(text: str) -> dict[str, Any] | None:
|
||||||
|
raw = text.strip()
|
||||||
|
fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL)
|
||||||
|
if fence:
|
||||||
|
raw = fence.group(1).strip()
|
||||||
|
start = raw.find("{")
|
||||||
|
end = raw.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
raw = raw[start : end + 1]
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _network_error_message(label: str, exc: BaseException, timeout: float) -> str:
|
||||||
|
reason = getattr(exc, "reason", exc)
|
||||||
|
if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError):
|
||||||
|
detail = f"timed out after {timeout:g}s"
|
||||||
|
else:
|
||||||
|
detail = str(reason) or type(reason).__name__
|
||||||
|
return f"{label} -> {detail}"
|
||||||
|
|
||||||
|
|
||||||
|
class ModelClient:
|
||||||
|
def __init__(self, model_config: dict[str, Any], timeout_seconds: float = 30):
|
||||||
|
self.model_config = model_config
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> bool:
|
||||||
|
return bool(
|
||||||
|
self.model_config.get("controller_model_proxy_url")
|
||||||
|
or (self.model_config.get("vllm_base_url") and self.model_config.get("vllm_model"))
|
||||||
|
)
|
||||||
|
|
||||||
|
def chat_json(self, messages: list[dict[str, str]], temperature: float | None = None) -> ModelResponse:
|
||||||
|
if not self.available:
|
||||||
|
return ModelResponse(ok=False, error="Model config is incomplete.")
|
||||||
|
|
||||||
|
proxy_url = str(self.model_config.get("controller_model_proxy_url") or "").strip()
|
||||||
|
if proxy_url:
|
||||||
|
return self._chat_json_via_controller(proxy_url, messages, temperature)
|
||||||
|
|
||||||
|
url = _chat_completions_url(str(self.model_config.get("vllm_base_url") or ""))
|
||||||
|
payload = {
|
||||||
|
"model": self.model_config.get("vllm_model"),
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature if temperature is not None else self.model_config.get("temperature", 0),
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
api_key = str(self.model_config.get("api_key") or "")
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
request = Request(url, data=body, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=self.timeout_seconds) as response:
|
||||||
|
raw = response.read()
|
||||||
|
except HTTPError as exc:
|
||||||
|
error_body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
return ModelResponse(
|
||||||
|
ok=False,
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
error=f"{url} -> HTTP {exc.code}: {error_body or exc.reason}",
|
||||||
|
)
|
||||||
|
except (URLError, TimeoutError, OSError) as exc:
|
||||||
|
return ModelResponse(
|
||||||
|
ok=False,
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
error=_network_error_message(url, exc, self.timeout_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = round(time.monotonic() - started, 3)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
choice = data["choices"][0]
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
content = (
|
||||||
|
message.get("content")
|
||||||
|
or message.get("reasoning_content")
|
||||||
|
or message.get("reasoning")
|
||||||
|
or choice.get("text")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
return ModelResponse(ok=False, duration_seconds=duration, error=f"Invalid model response: {exc}")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
content = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
return ModelResponse(
|
||||||
|
ok=True,
|
||||||
|
content=str(content),
|
||||||
|
parsed=parse_json_object(str(content)),
|
||||||
|
duration_seconds=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _chat_json_via_controller(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
messages: list[dict[str, str]],
|
||||||
|
temperature: float | None = None,
|
||||||
|
) -> ModelResponse:
|
||||||
|
payload = {
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature if temperature is not None else self.model_config.get("temperature", 0),
|
||||||
|
}
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
token = str(self.model_config.get("controller_model_proxy_token") or "")
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
request = Request(url, data=body, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=self.timeout_seconds) as response:
|
||||||
|
raw = response.read()
|
||||||
|
except HTTPError as exc:
|
||||||
|
error_body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
return ModelResponse(
|
||||||
|
ok=False,
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
error=f"controller model proxy -> HTTP {exc.code}: {error_body or exc.reason}",
|
||||||
|
)
|
||||||
|
except (URLError, TimeoutError, OSError) as exc:
|
||||||
|
return ModelResponse(
|
||||||
|
ok=False,
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
error=_network_error_message("controller model proxy", exc, self.timeout_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
duration = round(time.monotonic() - started, 3)
|
||||||
|
try:
|
||||||
|
data = json.loads(raw.decode("utf-8")) if raw else {}
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
return ModelResponse(ok=False, duration_seconds=duration, error=f"Invalid controller proxy response: {exc}")
|
||||||
|
|
||||||
|
content = str(data.get("content") or "")
|
||||||
|
parsed = data.get("parsed") if isinstance(data.get("parsed"), dict) else parse_json_object(content)
|
||||||
|
proxy_duration = data.get("duration_seconds")
|
||||||
|
try:
|
||||||
|
duration = float(proxy_duration) if proxy_duration is not None else duration
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return ModelResponse(
|
||||||
|
ok=bool(data.get("ok")),
|
||||||
|
content=content,
|
||||||
|
parsed=parsed,
|
||||||
|
duration_seconds=round(duration, 3),
|
||||||
|
error=str(data.get("error") or ""),
|
||||||
|
)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
HOST_AGENT_SYSTEM_PROMPT = """
|
||||||
|
你是系统测试 Agent 的 HostAgent,负责把一个待测系统拆解成可执行、可追踪、可恢复的测试任务。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
1. 阅读当前任务、任务层级、历史轨迹、页面观察结果和可用工具。
|
||||||
|
2. 判断当前任务是否需要继续拆分为子任务。
|
||||||
|
3. 将当前可执行范围交给 AppAgent,并说明目标、边界和风险。
|
||||||
|
4. 遇到账号、密码、验证码、权限、网络异常或破坏性操作时,不要硬做,标记为 PENDING 或 CONFIRM,并说明需要用户补充的信息。
|
||||||
|
5. 所有任务都必须保留层级关系,便于最终汇总成测试报告。
|
||||||
|
|
||||||
|
P0 阶段约束:
|
||||||
|
- 允许打开网页、观察页面、读取标题/状态、识别链接/表单/按钮、保存截图、生成子任务。
|
||||||
|
- 不允许自动提交真实表单、删除、支付、保存、发送消息或修改业务数据。
|
||||||
|
- 如果必须继续执行高风险动作,返回 CONFIRM。
|
||||||
|
|
||||||
|
输出必须是 JSON 对象:
|
||||||
|
{
|
||||||
|
"observation": "当前任务状态",
|
||||||
|
"thought": "调度判断",
|
||||||
|
"current_subtask": "分配给 AppAgent 的子任务",
|
||||||
|
"message": ["给 AppAgent 的约束或提示"],
|
||||||
|
"status": "FINISH | CONTINUE | PENDING | ASSIGN | FAIL",
|
||||||
|
"plan": ["后续子任务或测试方向"],
|
||||||
|
"function": "assign_app_agent | finish | ask_user | fail",
|
||||||
|
"arguments": {},
|
||||||
|
"questions": [],
|
||||||
|
"result": "调度结论"
|
||||||
|
}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
APP_AGENT_SYSTEM_PROMPT = """
|
||||||
|
你是系统测试 Agent 的 AppAgent,负责在单个网页或应用范围内执行 HostAgent 分配的测试子任务。
|
||||||
|
|
||||||
|
职责:
|
||||||
|
1. 打开待测页面并观察当前状态。
|
||||||
|
2. 识别页面中的功能入口:导航链接、按钮、表单、搜索框、登录入口、上传入口等。
|
||||||
|
3. 记录测试证据:URL、标题、HTTP 状态、可交互元素、截图路径、失败原因。
|
||||||
|
4. 如果页面需要账号密码、验证码、权限或网络不可达,标记 PENDING 并说明缺少什么。
|
||||||
|
|
||||||
|
P0 阶段约束:
|
||||||
|
- 只做安全观察和无副作用动作。
|
||||||
|
- 可以打开页面、读取结构、保存截图、枚举窗口和控件。
|
||||||
|
- 不要自动提交真实表单、删除、支付、保存、发送消息或修改业务数据。
|
||||||
|
- 高风险入口只记录并生成待确认任务,不要点击执行。
|
||||||
|
|
||||||
|
输出必须是 JSON 对象,不要输出 Markdown:
|
||||||
|
{
|
||||||
|
"observation": "当前页面或应用状态",
|
||||||
|
"thought": "为什么选择这一步",
|
||||||
|
"action": {
|
||||||
|
"function": "open_in_browser | observe_web_page | discover_web_subtasks | list_windows | inspect_window_controls | capture_desktop | capture_window | no_action",
|
||||||
|
"arguments": {},
|
||||||
|
"status": "CONTINUE | FINISH | FAIL | CONFIRM | PENDING"
|
||||||
|
},
|
||||||
|
"plan": ["后续动作或子任务方向"],
|
||||||
|
"comment": "给用户或上层 Agent 的简短说明",
|
||||||
|
"save_screenshot": {"save": false, "reason": ""},
|
||||||
|
"result": "本轮执行结论"
|
||||||
|
}
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_TEST_ANALYST_PROMPT = """
|
||||||
|
你是系统功能测试分析 Agent。你会收到一个页面观察结果、当前任务层级、可用链接/表单/按钮和历史记忆。
|
||||||
|
|
||||||
|
你的目标不是复述 HTML,而是像测试工程师一样判断“这个页面上有哪些业务功能值得测试”,并把它们拆成层级化测试子任务。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 根据页面标题、URL、链接文本、按钮文本、表单字段等推断功能模块。
|
||||||
|
2. 优先生成用户可理解的功能测试任务,例如“登录表单校验”“系统测试启动流程”“模型配置保存”“任务详情查看”。
|
||||||
|
3. 每个子任务必须给出测试目标 objective,而不是只给元素名称。
|
||||||
|
4. 如果需要账号、密码、验证码、权限或测试数据,写入 preconditions。
|
||||||
|
5. 识别风险:low 表示只读/导航/查看;medium 表示保存/提交/上传/新增/修改;high 表示删除/支付/清空/注销/不可逆。
|
||||||
|
6. P0 阶段不要求执行破坏性操作,只生成待确认任务。
|
||||||
|
7. 不要生成重复任务;最多生成 8 个高价值子任务。
|
||||||
|
8. 如果当前页面信息不足,也要说明原因,并可以返回空数组。
|
||||||
|
|
||||||
|
输出必须是严格 JSON 对象,不要输出 Markdown:
|
||||||
|
{
|
||||||
|
"summary": "对当前页面业务能力的简短判断",
|
||||||
|
"coverage_notes": ["本轮能够覆盖或不能覆盖的点"],
|
||||||
|
"needs_user_input": false,
|
||||||
|
"user_input_reason": "",
|
||||||
|
"functional_tasks": [
|
||||||
|
{
|
||||||
|
"title": "用户可读的测试任务标题",
|
||||||
|
"objective": "具体测试目标和通过标准",
|
||||||
|
"module": "所属模块",
|
||||||
|
"target_url": "可访问 URL,无法判断则留空",
|
||||||
|
"level": "module | feature | case",
|
||||||
|
"risk_level": "low | medium | high",
|
||||||
|
"priority": 10,
|
||||||
|
"preconditions": [],
|
||||||
|
"source": "llm_analysis",
|
||||||
|
"reason": "为什么生成这个任务"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".strip()
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
from .memory import Blackboard
|
||||||
|
from .model_client import ModelClient
|
||||||
|
from .prompts import APP_AGENT_SYSTEM_PROMPT, HOST_AGENT_SYSTEM_PROMPT, SYSTEM_TEST_ANALYST_PROMPT
|
||||||
|
from .tools import AgentToolRegistry, ToolResult, normalize_url
|
||||||
|
|
||||||
|
|
||||||
|
SubmitTask = Callable[[dict[str, Any]], dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentSessionResult:
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSession:
|
||||||
|
"""
|
||||||
|
Minimal UFO-inspired execution loop for one claimed task.
|
||||||
|
|
||||||
|
The implementation keeps the same conceptual boundaries as UFO:
|
||||||
|
HostAgent decides the subtask, AppAgent performs one safe step, Blackboard
|
||||||
|
persists requests/observations/trajectories for later rounds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
task: dict[str, Any],
|
||||||
|
model_config_state: dict[str, Any],
|
||||||
|
memory_path: Path,
|
||||||
|
request_timeout_seconds: float,
|
||||||
|
submit_task: SubmitTask | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.task = task
|
||||||
|
self.model_config_state = model_config_state
|
||||||
|
self.model_config = model_config_state.get("value") if isinstance(model_config_state.get("value"), dict) else {}
|
||||||
|
self.request_timeout_seconds = request_timeout_seconds
|
||||||
|
self.submit_task = submit_task
|
||||||
|
self.blackboard = Blackboard(memory_path)
|
||||||
|
self.tools = AgentToolRegistry()
|
||||||
|
self.model = ModelClient(self.model_config, timeout_seconds=self._model_timeout_seconds())
|
||||||
|
self.logs: list[str] = []
|
||||||
|
self.screenshot_dir = memory_path.parent / "screenshots"
|
||||||
|
|
||||||
|
def _model_timeout_seconds(self) -> float:
|
||||||
|
try:
|
||||||
|
timeout = float(self.model_config.get("request_timeout_seconds") or self.request_timeout_seconds)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timeout = self.request_timeout_seconds
|
||||||
|
return max(1.0, min(timeout, 300.0))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def payload(self) -> dict[str, Any]:
|
||||||
|
value = self.task.get("payload")
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def task_id(self) -> str:
|
||||||
|
return str(self.task.get("external_id") or self.task.get("id") or "")
|
||||||
|
|
||||||
|
def target_url(self) -> str:
|
||||||
|
return normalize_url(
|
||||||
|
self.payload.get("target_url")
|
||||||
|
or self.payload.get("url")
|
||||||
|
or self.payload.get("site_url")
|
||||||
|
or self.task.get("target_url")
|
||||||
|
or self.model_config.get("target_system_url")
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self) -> dict[str, Any]:
|
||||||
|
started = time.monotonic()
|
||||||
|
self.blackboard.append(
|
||||||
|
"requests",
|
||||||
|
{
|
||||||
|
"task": self._task_brief(),
|
||||||
|
"model_config_version": self.model_config_state.get("version"),
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
host_decision = self._host_decision()
|
||||||
|
if self.execution_mode() == "gui":
|
||||||
|
return self._run_gui_task(started, host_decision)
|
||||||
|
|
||||||
|
target_url = self.target_url()
|
||||||
|
if not target_url:
|
||||||
|
return self._finish_without_target(started, host_decision)
|
||||||
|
|
||||||
|
open_browser_result: ToolResult | None = None
|
||||||
|
if self.payload.get("open_in_browser", True):
|
||||||
|
open_browser_result = self.tools.web.open_in_browser(
|
||||||
|
target_url,
|
||||||
|
wait_seconds=float(self.payload.get("browser_wait_seconds") or 1.5),
|
||||||
|
)
|
||||||
|
self.blackboard.append(
|
||||||
|
"actions",
|
||||||
|
{
|
||||||
|
"tool": "open_in_browser",
|
||||||
|
"ok": open_browser_result.ok,
|
||||||
|
"error": open_browser_result.error,
|
||||||
|
"result": open_browser_result.result,
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
if open_browser_result.ok:
|
||||||
|
self.logs.append(f"Opened target URL in browser: {target_url}")
|
||||||
|
else:
|
||||||
|
self.logs.append(f"Browser open failed: {open_browser_result.error}")
|
||||||
|
|
||||||
|
observation_result = self.tools.web.observe_page(
|
||||||
|
target_url,
|
||||||
|
timeout_seconds=self.request_timeout_seconds,
|
||||||
|
)
|
||||||
|
observation = observation_result.result
|
||||||
|
desktop_screenshot_result: ToolResult | None = None
|
||||||
|
if self.payload.get("capture_screenshot", True):
|
||||||
|
desktop_screenshot_result = self.tools.gui.capture_desktop(
|
||||||
|
self.screenshot_dir,
|
||||||
|
prefix=f"task_{self.task.get('id') or self.task_id}_web",
|
||||||
|
)
|
||||||
|
if desktop_screenshot_result.ok:
|
||||||
|
self.blackboard.append(
|
||||||
|
"screenshots",
|
||||||
|
{
|
||||||
|
"image_path": desktop_screenshot_result.result.get("image_path"),
|
||||||
|
"scope": "web_desktop",
|
||||||
|
"target_url": target_url,
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
elif desktop_screenshot_result.error:
|
||||||
|
self.logs.append(f"Desktop screenshot failed: {desktop_screenshot_result.error}")
|
||||||
|
|
||||||
|
self.blackboard.append(
|
||||||
|
"observations",
|
||||||
|
{
|
||||||
|
"tool": "observe_web_page",
|
||||||
|
"ok": observation_result.ok,
|
||||||
|
"error": observation_result.error,
|
||||||
|
"browser_open": open_browser_result.result if open_browser_result else None,
|
||||||
|
"screenshot": desktop_screenshot_result.result if desktop_screenshot_result and desktop_screenshot_result.ok else None,
|
||||||
|
"summary": self._observation_summary(observation),
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_decision = self._app_decision(host_decision, observation_result)
|
||||||
|
discovered_children: list[dict[str, Any]] = []
|
||||||
|
model_generated_children: list[dict[str, Any]] = []
|
||||||
|
rule_discovered_children: list[dict[str, Any]] = []
|
||||||
|
submitted_children: list[dict[str, Any]] = []
|
||||||
|
model_analysis: dict[str, Any] = {
|
||||||
|
"model_used": False,
|
||||||
|
"model_available": self.model.available,
|
||||||
|
}
|
||||||
|
discovery_source = "none"
|
||||||
|
if self._allow_child_discovery(observation_result):
|
||||||
|
model_analysis, model_generated_children = self._model_discover_child_tasks(observation_result)
|
||||||
|
if model_generated_children:
|
||||||
|
discovered_children = model_generated_children
|
||||||
|
discovery_source = "llm"
|
||||||
|
else:
|
||||||
|
rule_discovered_children = self.tools.web.discover_child_tasks(
|
||||||
|
self.task,
|
||||||
|
observation,
|
||||||
|
max_depth=int(self.payload.get("discovery_max_depth") or 2),
|
||||||
|
)
|
||||||
|
discovered_children = rule_discovered_children
|
||||||
|
discovery_source = "rule"
|
||||||
|
if model_analysis.get("model_available") and model_analysis.get("model_used"):
|
||||||
|
self.logs.append("Model returned no usable functional tasks; used rule-based fallback.")
|
||||||
|
submitted_children = self._submit_child_tasks(discovered_children)
|
||||||
|
|
||||||
|
success, message, needs_user_input = self._status_from_observation(observation_result, app_decision)
|
||||||
|
if model_analysis.get("needs_user_input"):
|
||||||
|
needs_user_input = True
|
||||||
|
success = False
|
||||||
|
message = str(model_analysis.get("user_input_reason") or "Model determined that user input is required.")
|
||||||
|
duration = round(time.monotonic() - started, 3)
|
||||||
|
result = {
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"task_type": self.payload.get("task_type") or "manual",
|
||||||
|
"agent_framework": "ufo-inspired-lite",
|
||||||
|
"p0_mode": True,
|
||||||
|
"needs_user_input": needs_user_input,
|
||||||
|
"target_url": target_url,
|
||||||
|
"final_url": observation.get("final_url"),
|
||||||
|
"http_status": observation.get("http_status"),
|
||||||
|
"content_type": observation.get("content_type"),
|
||||||
|
"page_title": observation.get("page_title"),
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"action_duration_seconds": round(
|
||||||
|
observation_result.duration_seconds
|
||||||
|
+ (open_browser_result.duration_seconds if open_browser_result else 0)
|
||||||
|
+ (desktop_screenshot_result.duration_seconds if desktop_screenshot_result else 0),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
"model_duration_seconds": round(
|
||||||
|
self._model_duration(host_decision, app_decision)
|
||||||
|
+ float(model_analysis.get("model_duration_seconds") or 0),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
"hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(),
|
||||||
|
"host_decision": host_decision,
|
||||||
|
"app_decision": app_decision,
|
||||||
|
"model_available": self.model.available,
|
||||||
|
"model_analysis": model_analysis,
|
||||||
|
"model_errors": self._model_errors(host_decision, app_decision, model_analysis),
|
||||||
|
"observation": self._observation_summary(observation),
|
||||||
|
"browser_open": open_browser_result.result if open_browser_result else None,
|
||||||
|
"screenshots": [
|
||||||
|
desktop_screenshot_result.result
|
||||||
|
] if desktop_screenshot_result and desktop_screenshot_result.ok else [],
|
||||||
|
"available_tools": self.tools.descriptions(),
|
||||||
|
"discovered_child_tasks": self._child_task_briefs(discovered_children),
|
||||||
|
"model_generated_child_tasks": self._child_task_briefs(model_generated_children),
|
||||||
|
"rule_discovered_child_tasks": self._child_task_briefs(rule_discovered_children),
|
||||||
|
"discovery_source": discovery_source,
|
||||||
|
"submitted_child_tasks": submitted_children,
|
||||||
|
"memory": self.blackboard.summary(),
|
||||||
|
"logs": [*self.logs, *(observation_result.result.get("logs") or [])],
|
||||||
|
}
|
||||||
|
self.blackboard.append(
|
||||||
|
"trajectories",
|
||||||
|
{
|
||||||
|
"host_decision": host_decision,
|
||||||
|
"app_decision": app_decision,
|
||||||
|
"result": {
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"submitted_child_count": len(submitted_children),
|
||||||
|
"duration_seconds": duration,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
result["memory"] = self.blackboard.summary()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def execution_mode(self) -> str:
|
||||||
|
explicit = str(self.payload.get("execution_mode") or self.payload.get("agent_mode") or "").strip().lower()
|
||||||
|
if explicit in {"web", "gui"}:
|
||||||
|
return explicit
|
||||||
|
task_type = str(self.payload.get("task_type") or "").lower()
|
||||||
|
if task_type.startswith("gui") or self.payload.get("window_id") or self.payload.get("window_title"):
|
||||||
|
return "gui"
|
||||||
|
return "web"
|
||||||
|
|
||||||
|
def _run_gui_task(self, started: float, host_decision: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
windows_result = self.tools.gui.list_windows()
|
||||||
|
screenshot_result = self.tools.gui.capture_desktop(self.screenshot_dir, prefix=f"task_{self.task.get('id') or self.task_id}_desktop")
|
||||||
|
selected_window = self._select_window(windows_result.result.get("windows") or [])
|
||||||
|
inspect_result: ToolResult | None = None
|
||||||
|
window_screenshot_result: ToolResult | None = None
|
||||||
|
if selected_window:
|
||||||
|
inspect_result = self.tools.gui.inspect_window(str(selected_window["id"]))
|
||||||
|
window_screenshot_result = self.tools.gui.capture_window(
|
||||||
|
str(selected_window["id"]),
|
||||||
|
self.screenshot_dir,
|
||||||
|
prefix=f"task_{self.task.get('id') or self.task_id}_window",
|
||||||
|
)
|
||||||
|
|
||||||
|
observation = self._gui_observation_summary(
|
||||||
|
windows_result,
|
||||||
|
screenshot_result,
|
||||||
|
selected_window,
|
||||||
|
inspect_result,
|
||||||
|
window_screenshot_result,
|
||||||
|
)
|
||||||
|
self.blackboard.append(
|
||||||
|
"observations",
|
||||||
|
{
|
||||||
|
"tool": "gui_observe",
|
||||||
|
"ok": windows_result.ok,
|
||||||
|
"error": windows_result.error,
|
||||||
|
"summary": observation,
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
if screenshot_result.ok:
|
||||||
|
self.blackboard.append(
|
||||||
|
"screenshots",
|
||||||
|
{"image_path": screenshot_result.result.get("image_path"), "scope": "desktop"},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
if window_screenshot_result and window_screenshot_result.ok:
|
||||||
|
self.blackboard.append(
|
||||||
|
"screenshots",
|
||||||
|
{"image_path": window_screenshot_result.result.get("image_path"), "scope": "window", "window": selected_window},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
app_decision = {
|
||||||
|
"observation": json.dumps(observation, ensure_ascii=False),
|
||||||
|
"thought": "GUI P0 阶段只枚举窗口、控件并保存证据截图,不执行点击或输入。",
|
||||||
|
"action": {
|
||||||
|
"function": "list_windows" if not selected_window else "inspect_window_controls",
|
||||||
|
"arguments": {
|
||||||
|
"window_id": selected_window.get("id") if selected_window else None,
|
||||||
|
"window_title": self.payload.get("window_title"),
|
||||||
|
},
|
||||||
|
"status": "FINISH" if windows_result.ok and (not self.payload.get("window_title") or selected_window) else "FAIL",
|
||||||
|
},
|
||||||
|
"plan": [],
|
||||||
|
"comment": "GUI 观察完成。" if windows_result.ok else windows_result.error,
|
||||||
|
"save_screenshot": {
|
||||||
|
"save": bool(screenshot_result.ok or (window_screenshot_result and window_screenshot_result.ok)),
|
||||||
|
"reason": "GUI P0 观察需要截图作为证据。" if screenshot_result.ok else screenshot_result.error,
|
||||||
|
},
|
||||||
|
"result": "GUI 窗口和控件信息已记录。" if windows_result.ok else windows_result.error,
|
||||||
|
"model_used": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
success = windows_result.ok and (not self.payload.get("window_title") or bool(selected_window))
|
||||||
|
message = "Agent GUI P0 observation completed." if success else "Requested GUI window was not found or could not be inspected."
|
||||||
|
duration = round(time.monotonic() - started, 3)
|
||||||
|
result = {
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"task_type": self.payload.get("task_type") or "gui_p0",
|
||||||
|
"agent_framework": "ufo-inspired-lite",
|
||||||
|
"p0_mode": True,
|
||||||
|
"execution_mode": "gui",
|
||||||
|
"needs_user_input": False,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"action_duration_seconds": round(
|
||||||
|
windows_result.duration_seconds
|
||||||
|
+ screenshot_result.duration_seconds
|
||||||
|
+ (inspect_result.duration_seconds if inspect_result else 0)
|
||||||
|
+ (window_screenshot_result.duration_seconds if window_screenshot_result else 0),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
"model_duration_seconds": self._model_duration(host_decision, app_decision),
|
||||||
|
"hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(),
|
||||||
|
"host_decision": host_decision,
|
||||||
|
"app_decision": app_decision,
|
||||||
|
"observation": observation,
|
||||||
|
"available_tools": self.tools.descriptions(),
|
||||||
|
"memory": self.blackboard.summary(),
|
||||||
|
"logs": [
|
||||||
|
*self.logs,
|
||||||
|
*(windows_result.error and [windows_result.error] or []),
|
||||||
|
*(screenshot_result.error and [screenshot_result.error] or []),
|
||||||
|
*(inspect_result and inspect_result.error and [inspect_result.error] or []),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
self.blackboard.append(
|
||||||
|
"trajectories",
|
||||||
|
{
|
||||||
|
"host_decision": host_decision,
|
||||||
|
"app_decision": app_decision,
|
||||||
|
"result": {
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
task_id=self.task_id,
|
||||||
|
)
|
||||||
|
result["memory"] = self.blackboard.summary()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _host_decision(self) -> dict[str, Any]:
|
||||||
|
fallback = {
|
||||||
|
"observation": "收到一个主控端分配的测试任务。",
|
||||||
|
"thought": "P0 阶段先把任务限定到单个 Web/AppAgent,执行无副作用观察,并按页面结构生成后续子任务。",
|
||||||
|
"current_subtask": self.payload.get("objective") or self.payload.get("instruction") or self.task.get("title") or "",
|
||||||
|
"message": ["只执行无副作用观察动作;遇到登录、验证码、权限或破坏性入口时暂停并回传。"],
|
||||||
|
"status": "ASSIGN",
|
||||||
|
"plan": ["观察目标页面", "记录页面元信息", "识别可继续拆分的链接、表单和入口"],
|
||||||
|
"function": "assign_app_agent",
|
||||||
|
"arguments": {"agent": "web_app_agent", "target_url": self.target_url()},
|
||||||
|
"questions": [],
|
||||||
|
"result": "已分配给 Web AppAgent 执行 P0 观察。",
|
||||||
|
"model_used": False,
|
||||||
|
}
|
||||||
|
if not self.model.available:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"task": self._task_brief(),
|
||||||
|
"blackboard": self.blackboard.snapshot(limit=5),
|
||||||
|
"tools": self.tools.descriptions(),
|
||||||
|
}
|
||||||
|
response = self.model.chat_json(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": HOST_AGENT_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": json.dumps(context, ensure_ascii=False)},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if response.ok and isinstance(response.parsed, dict):
|
||||||
|
parsed = {**fallback, **response.parsed, "model_used": True, "model_duration_seconds": response.duration_seconds}
|
||||||
|
return parsed
|
||||||
|
self.logs.append(f"HostAgent model fallback: {response.error or 'invalid JSON response'}")
|
||||||
|
return {**fallback, "model_error": response.error, "model_duration_seconds": response.duration_seconds}
|
||||||
|
|
||||||
|
def _app_decision(self, host_decision: dict[str, Any], observation_result: ToolResult) -> dict[str, Any]:
|
||||||
|
observation = self._observation_summary(observation_result.result)
|
||||||
|
fallback_status = "FINISH" if observation_result.ok else "FAIL"
|
||||||
|
if observation.get("requires_auth"):
|
||||||
|
fallback_status = "PENDING"
|
||||||
|
fallback = {
|
||||||
|
"observation": json.dumps(observation, ensure_ascii=False),
|
||||||
|
"thought": "P0 阶段采用 observe_web_page 工具完成可达性和页面结构观察,不执行提交、保存、删除等有副作用动作。",
|
||||||
|
"action": {
|
||||||
|
"function": "observe_web_page",
|
||||||
|
"arguments": {"target_url": self.target_url()},
|
||||||
|
"status": fallback_status,
|
||||||
|
},
|
||||||
|
"plan": [],
|
||||||
|
"comment": "页面观察完成。" if observation_result.ok else observation_result.error,
|
||||||
|
"save_screenshot": {"save": False, "reason": "P0 Web 观察暂不保存截图。"},
|
||||||
|
"result": "页面可达性和结构信息已记录。" if observation_result.ok else observation_result.error,
|
||||||
|
"model_used": False,
|
||||||
|
}
|
||||||
|
if not self.model.available:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"host_decision": host_decision,
|
||||||
|
"task": self._task_brief(),
|
||||||
|
"observation": observation,
|
||||||
|
"available_tools": self.tools.descriptions(),
|
||||||
|
"recent_trajectories": self.blackboard.recent("trajectories", 5),
|
||||||
|
}
|
||||||
|
response = self.model.chat_json(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": APP_AGENT_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": json.dumps(context, ensure_ascii=False)},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if response.ok and isinstance(response.parsed, dict):
|
||||||
|
parsed = {**fallback, **response.parsed, "model_used": True, "model_duration_seconds": response.duration_seconds}
|
||||||
|
return parsed
|
||||||
|
self.logs.append(f"AppAgent model fallback: {response.error or 'invalid JSON response'}")
|
||||||
|
return {**fallback, "model_error": response.error, "model_duration_seconds": response.duration_seconds}
|
||||||
|
|
||||||
|
def _model_discover_child_tasks(self, observation_result: ToolResult) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
|
base_analysis: dict[str, Any] = {
|
||||||
|
"model_available": self.model.available,
|
||||||
|
"model_called": False,
|
||||||
|
"model_call_succeeded": False,
|
||||||
|
"model_used": False,
|
||||||
|
"model_duration_seconds": 0,
|
||||||
|
"summary": "",
|
||||||
|
"coverage_notes": [],
|
||||||
|
"needs_user_input": False,
|
||||||
|
"user_input_reason": "",
|
||||||
|
"functional_task_count": 0,
|
||||||
|
}
|
||||||
|
if not observation_result.ok:
|
||||||
|
return {**base_analysis, "error": observation_result.error or "Page observation failed."}, []
|
||||||
|
if not self.model.available:
|
||||||
|
self.logs.append("Model config is incomplete; used rule-based discovery fallback.")
|
||||||
|
return {**base_analysis, "error": "Model config is incomplete."}, []
|
||||||
|
|
||||||
|
observation = observation_result.result
|
||||||
|
context = {
|
||||||
|
"task": self._task_brief(),
|
||||||
|
"hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(),
|
||||||
|
"page": self._observation_for_model(observation),
|
||||||
|
"recent_memory": self.blackboard.snapshot(limit=4),
|
||||||
|
"constraints": {
|
||||||
|
"p0_no_side_effects": True,
|
||||||
|
"max_functional_tasks": 8,
|
||||||
|
"discovery_max_depth": int(self.payload.get("discovery_max_depth") or 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = self.model.chat_json(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": SYSTEM_TEST_ANALYST_PROMPT},
|
||||||
|
{"role": "user", "content": json.dumps(context, ensure_ascii=False)},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if not response.ok or not isinstance(response.parsed, dict):
|
||||||
|
self.logs.append(f"SystemTestAnalyst model fallback: {response.error or 'invalid JSON response'}")
|
||||||
|
return {
|
||||||
|
**base_analysis,
|
||||||
|
"model_called": True,
|
||||||
|
"model_call_succeeded": False,
|
||||||
|
"model_used": False,
|
||||||
|
"model_duration_seconds": response.duration_seconds,
|
||||||
|
"error": response.error or "Model did not return a valid JSON object.",
|
||||||
|
}, []
|
||||||
|
|
||||||
|
parsed = response.parsed
|
||||||
|
children = self._children_from_model_analysis(parsed, observation)
|
||||||
|
analysis = {
|
||||||
|
**base_analysis,
|
||||||
|
"model_called": True,
|
||||||
|
"model_call_succeeded": True,
|
||||||
|
"model_used": True,
|
||||||
|
"model_duration_seconds": response.duration_seconds,
|
||||||
|
"summary": str(parsed.get("summary") or ""),
|
||||||
|
"coverage_notes": parsed.get("coverage_notes") if isinstance(parsed.get("coverage_notes"), list) else [],
|
||||||
|
"needs_user_input": bool(parsed.get("needs_user_input")),
|
||||||
|
"user_input_reason": str(parsed.get("user_input_reason") or ""),
|
||||||
|
"functional_task_count": len(children),
|
||||||
|
}
|
||||||
|
self.logs.append(
|
||||||
|
"Model analyzed page and generated "
|
||||||
|
f"{len(children)} functional task(s): {analysis.get('summary') or 'no summary'}"
|
||||||
|
)
|
||||||
|
return analysis, children
|
||||||
|
|
||||||
|
def _observation_for_model(self, observation: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
def clean_items(items: Any, limit: int) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return []
|
||||||
|
cleaned: list[dict[str, Any]] = []
|
||||||
|
for item in items[:limit]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
cleaned.append(
|
||||||
|
{
|
||||||
|
key: value
|
||||||
|
for key, value in item.items()
|
||||||
|
if key in {"href", "text", "action", "method", "id", "name", "class", "type", "fields", "placeholder", "aria_label", "in_form"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
return {
|
||||||
|
"url": observation.get("url"),
|
||||||
|
"final_url": observation.get("final_url"),
|
||||||
|
"http_status": observation.get("http_status"),
|
||||||
|
"content_type": observation.get("content_type"),
|
||||||
|
"page_title": observation.get("page_title"),
|
||||||
|
"requires_auth": observation.get("requires_auth"),
|
||||||
|
"has_login_form": observation.get("has_login_form"),
|
||||||
|
"links": clean_items(observation.get("links"), 20),
|
||||||
|
"forms": clean_items(observation.get("forms"), 12),
|
||||||
|
"buttons": clean_items(observation.get("buttons"), 20),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _children_from_model_analysis(self, analysis: dict[str, Any], observation: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
raw_items = analysis.get("functional_tasks")
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
return []
|
||||||
|
max_children = 8
|
||||||
|
children: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for index, item in enumerate(raw_items[:max_children], start=1):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
child = self._child_from_model_item(item, observation, index)
|
||||||
|
key = (
|
||||||
|
str(child.get("title") or "").strip().lower(),
|
||||||
|
str((child.get("payload") or {}).get("target_url") or "").strip().lower(),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
children.append(child)
|
||||||
|
return children
|
||||||
|
|
||||||
|
def _child_from_model_item(self, item: dict[str, Any], observation: dict[str, Any], index: int) -> dict[str, Any]:
|
||||||
|
parent_external_id = str(self.task.get("external_id") or self.task.get("id") or "")
|
||||||
|
root_external_id = str(self.task.get("root_external_id") or self.payload.get("root_external_id") or parent_external_id)
|
||||||
|
parent_depth = int(self.task.get("depth") or self.payload.get("depth") or 0)
|
||||||
|
depth = parent_depth + 1
|
||||||
|
discovery_max_depth = int(self.payload.get("discovery_max_depth") or 2)
|
||||||
|
base_url = observation.get("final_url") or observation.get("url") or self.target_url()
|
||||||
|
raw_target_url = str(item.get("target_url") or "").strip()
|
||||||
|
target_url = urljoin(base_url, raw_target_url) if raw_target_url else base_url
|
||||||
|
title = str(item.get("title") or f"模型生成测试任务 {index}").strip()
|
||||||
|
objective = str(item.get("objective") or title).strip()
|
||||||
|
module = str(item.get("module") or title).strip()
|
||||||
|
level = str(item.get("level") or "feature").strip().lower()
|
||||||
|
if level not in {"module", "feature", "case"}:
|
||||||
|
level = "feature"
|
||||||
|
risk = str(item.get("risk_level") or "low").strip().lower()
|
||||||
|
if risk not in {"low", "medium", "high"}:
|
||||||
|
risk = "low"
|
||||||
|
try:
|
||||||
|
priority = int(item.get("priority") or 30 + depth * 10 + index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
priority = 30 + depth * 10 + index
|
||||||
|
preconditions = item.get("preconditions") if isinstance(item.get("preconditions"), list) else []
|
||||||
|
external_id = self._stable_child_id(root_external_id, parent_external_id, title, target_url, objective)
|
||||||
|
payload = {
|
||||||
|
"task_type": "smart_discovery_p0",
|
||||||
|
"instruction": objective,
|
||||||
|
"objective": objective,
|
||||||
|
"target_url": target_url,
|
||||||
|
"source": "llm_analysis",
|
||||||
|
"llm_reason": item.get("reason") or "",
|
||||||
|
"level": level,
|
||||||
|
"depth": depth,
|
||||||
|
"risk_level": risk,
|
||||||
|
"allow_child_discovery": level in {"module", "feature"} and depth < discovery_max_depth,
|
||||||
|
"discovery_max_depth": discovery_max_depth,
|
||||||
|
"open_in_browser": True,
|
||||||
|
"capture_screenshot": True,
|
||||||
|
"preconditions": preconditions,
|
||||||
|
"hierarchy": {
|
||||||
|
"id": external_id,
|
||||||
|
"parent_id": parent_external_id,
|
||||||
|
"root_id": root_external_id,
|
||||||
|
"depth": depth,
|
||||||
|
"level": level,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"external_id": external_id,
|
||||||
|
"title": title,
|
||||||
|
"module": module,
|
||||||
|
"parent_external_id": parent_external_id,
|
||||||
|
"root_external_id": root_external_id,
|
||||||
|
"depth": depth,
|
||||||
|
"task_level": level,
|
||||||
|
"risk_level": risk,
|
||||||
|
"priority": priority,
|
||||||
|
"failure_policy": "pause" if risk in {"medium", "high"} or preconditions else "skip",
|
||||||
|
"max_retries": 2,
|
||||||
|
"timeout_seconds": 90,
|
||||||
|
"tags": ["llm-generated", "system-test", "p0"],
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _stable_child_id(self, *parts: Any) -> str:
|
||||||
|
raw = "|".join(str(part or "") for part in parts)
|
||||||
|
return f"llm-{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:12]}"
|
||||||
|
|
||||||
|
def _allow_child_discovery(self, observation_result: ToolResult) -> bool:
|
||||||
|
if not observation_result.ok:
|
||||||
|
return False
|
||||||
|
allow = self.payload.get("allow_child_discovery")
|
||||||
|
if allow is not None:
|
||||||
|
return bool(allow)
|
||||||
|
if self.payload.get("task_type") != "smart_discovery_p0":
|
||||||
|
return False
|
||||||
|
level = str(self.payload.get("level") or self.task.get("task_level") or "")
|
||||||
|
depth = int(self.payload.get("depth") or self.task.get("depth") or 0)
|
||||||
|
return level in {"site", "module", ""} and depth < int(self.payload.get("discovery_max_depth") or 2)
|
||||||
|
|
||||||
|
def _submit_child_tasks(self, children: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not children or self.submit_task is None:
|
||||||
|
return []
|
||||||
|
submitted: list[dict[str, Any]] = []
|
||||||
|
plan_name = self.task.get("plan_name") or self.payload.get("plan_name") or "智能探索 P0"
|
||||||
|
for child in children:
|
||||||
|
payload = {**child, "plan_name": plan_name}
|
||||||
|
try:
|
||||||
|
created = self.submit_task(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logs.append(f"Submit child task failed: {child.get('external_id')} error={exc}")
|
||||||
|
continue
|
||||||
|
submitted.append(
|
||||||
|
{
|
||||||
|
"external_id": created.get("external_id") or child.get("external_id"),
|
||||||
|
"title": created.get("title") or child.get("title"),
|
||||||
|
"depth": created.get("depth") if created.get("depth") is not None else child.get("depth"),
|
||||||
|
"task_level": created.get("task_level") or child.get("task_level"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return submitted
|
||||||
|
|
||||||
|
def _status_from_observation(self, observation_result: ToolResult, app_decision: dict[str, Any]) -> tuple[bool, str, bool]:
|
||||||
|
action = app_decision.get("action") if isinstance(app_decision.get("action"), dict) else {}
|
||||||
|
status = str(action.get("status") or "").upper()
|
||||||
|
if status == "PENDING":
|
||||||
|
return False, "Task needs user input before it can continue.", True
|
||||||
|
if status in {"FAIL", "CONFIRM"}:
|
||||||
|
return False, app_decision.get("result") or app_decision.get("comment") or "Agent action did not complete.", status == "CONFIRM"
|
||||||
|
if not observation_result.ok:
|
||||||
|
return False, observation_result.error or "Target observation failed.", False
|
||||||
|
if observation_result.result.get("http_status") in {401, 403}:
|
||||||
|
return False, "Target page requires authentication or permission.", True
|
||||||
|
return True, "Agent P0 observation completed.", False
|
||||||
|
|
||||||
|
def _finish_without_target(self, started: float, host_decision: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
task_type = self.payload.get("task_type") or "manual"
|
||||||
|
success = task_type != "smart_discovery_p0"
|
||||||
|
duration = round(time.monotonic() - started, 3)
|
||||||
|
result = {
|
||||||
|
"success": success,
|
||||||
|
"message": "No executable target URL was provided." if not success else "Task accepted; no executable URL was provided for P0 mode.",
|
||||||
|
"task_type": task_type,
|
||||||
|
"agent_framework": "ufo-inspired-lite",
|
||||||
|
"p0_mode": True,
|
||||||
|
"needs_user_input": not success,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
"hierarchy": self.payload.get("hierarchy") or self._task_hierarchy(),
|
||||||
|
"host_decision": host_decision,
|
||||||
|
"app_decision": {
|
||||||
|
"action": {"function": "no_action", "arguments": {}, "status": "FAIL" if not success else "FINISH"},
|
||||||
|
"result": "Missing target URL.",
|
||||||
|
},
|
||||||
|
"available_tools": self.tools.descriptions(),
|
||||||
|
"memory": self.blackboard.summary(),
|
||||||
|
"logs": ["AgentSession requires target_url for Web P0 execution."],
|
||||||
|
}
|
||||||
|
self.blackboard.append("trajectories", {"host_decision": host_decision, "result": result}, task_id=self.task_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _select_window(self, windows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||||
|
window_id = self.payload.get("window_id")
|
||||||
|
if window_id:
|
||||||
|
for window in windows:
|
||||||
|
if str(window.get("id")) == str(window_id) or str(window.get("hex_id")).lower() == str(window_id).lower():
|
||||||
|
return window
|
||||||
|
return None
|
||||||
|
|
||||||
|
title = str(self.payload.get("window_title") or self.payload.get("application") or "").strip().lower()
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
for window in windows:
|
||||||
|
if title in str(window.get("title") or "").lower():
|
||||||
|
return window
|
||||||
|
for window in windows:
|
||||||
|
if title in str(window.get("class_name") or "").lower():
|
||||||
|
return window
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _gui_observation_summary(
|
||||||
|
self,
|
||||||
|
windows_result: ToolResult,
|
||||||
|
screenshot_result: ToolResult,
|
||||||
|
selected_window: dict[str, Any] | None,
|
||||||
|
inspect_result: ToolResult | None,
|
||||||
|
window_screenshot_result: ToolResult | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
windows = windows_result.result.get("windows") if isinstance(windows_result.result, dict) else []
|
||||||
|
controls = []
|
||||||
|
if inspect_result and isinstance(inspect_result.result, dict):
|
||||||
|
controls = inspect_result.result.get("controls") or []
|
||||||
|
return {
|
||||||
|
"backend": (windows_result.result or {}).get("backend") or self.tools.gui.backend_status(),
|
||||||
|
"window_count": len(windows or []),
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": window.get("id"),
|
||||||
|
"title": window.get("title"),
|
||||||
|
"class_name": window.get("class_name"),
|
||||||
|
"process_id": window.get("process_id"),
|
||||||
|
"rect": window.get("rect"),
|
||||||
|
"minimized": window.get("minimized"),
|
||||||
|
}
|
||||||
|
for window in (windows or [])[:20]
|
||||||
|
],
|
||||||
|
"selected_window": selected_window,
|
||||||
|
"control_count": len(controls),
|
||||||
|
"controls": [
|
||||||
|
{
|
||||||
|
"id": control.get("id"),
|
||||||
|
"text": control.get("text"),
|
||||||
|
"class_name": control.get("class_name"),
|
||||||
|
"rect": control.get("rect"),
|
||||||
|
"visible": control.get("visible"),
|
||||||
|
"enabled": control.get("enabled"),
|
||||||
|
}
|
||||||
|
for control in controls[:80]
|
||||||
|
],
|
||||||
|
"desktop_screenshot": screenshot_result.result if screenshot_result.ok else None,
|
||||||
|
"window_screenshot": window_screenshot_result.result if window_screenshot_result and window_screenshot_result.ok else None,
|
||||||
|
"errors": [
|
||||||
|
message
|
||||||
|
for message in [
|
||||||
|
windows_result.error,
|
||||||
|
screenshot_result.error,
|
||||||
|
inspect_result.error if inspect_result else "",
|
||||||
|
window_screenshot_result.error if window_screenshot_result else "",
|
||||||
|
]
|
||||||
|
if message
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _task_brief(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.task.get("id"),
|
||||||
|
"external_id": self.task.get("external_id"),
|
||||||
|
"title": self.task.get("title"),
|
||||||
|
"plan_name": self.task.get("plan_name"),
|
||||||
|
"module": self.task.get("module"),
|
||||||
|
"status": self.task.get("status"),
|
||||||
|
"depth": self.task.get("depth"),
|
||||||
|
"task_level": self.task.get("task_level"),
|
||||||
|
"risk_level": self.task.get("risk_level"),
|
||||||
|
"payload": self.payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _task_hierarchy(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.task.get("external_id") or self.task.get("id"),
|
||||||
|
"parent_id": self.task.get("parent_external_id"),
|
||||||
|
"root_id": self.task.get("root_external_id") or self.task.get("external_id") or self.task.get("id"),
|
||||||
|
"depth": self.task.get("depth") or 0,
|
||||||
|
"level": self.task.get("task_level") or self.payload.get("level") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _observation_summary(self, observation: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"url": observation.get("url"),
|
||||||
|
"final_url": observation.get("final_url"),
|
||||||
|
"http_status": observation.get("http_status"),
|
||||||
|
"content_type": observation.get("content_type"),
|
||||||
|
"page_title": observation.get("page_title"),
|
||||||
|
"requires_auth": observation.get("requires_auth"),
|
||||||
|
"auth_challenge": observation.get("auth_challenge"),
|
||||||
|
"has_login_form": observation.get("has_login_form"),
|
||||||
|
"link_count": len(observation.get("links") or []),
|
||||||
|
"form_count": len(observation.get("forms") or []),
|
||||||
|
"button_count": len(observation.get("buttons") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _child_task_briefs(self, children: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"external_id": child.get("external_id"),
|
||||||
|
"title": child.get("title"),
|
||||||
|
"parent_external_id": child.get("parent_external_id"),
|
||||||
|
"root_external_id": child.get("root_external_id"),
|
||||||
|
"depth": child.get("depth"),
|
||||||
|
"task_level": child.get("task_level"),
|
||||||
|
"risk_level": child.get("risk_level"),
|
||||||
|
"target_url": (child.get("payload") or {}).get("target_url"),
|
||||||
|
"source": (child.get("payload") or {}).get("source"),
|
||||||
|
"preconditions": (child.get("payload") or {}).get("preconditions") or [],
|
||||||
|
"llm_reason": (child.get("payload") or {}).get("llm_reason") or "",
|
||||||
|
}
|
||||||
|
for child in children
|
||||||
|
]
|
||||||
|
|
||||||
|
def _model_duration(self, host_decision: dict[str, Any], app_decision: dict[str, Any]) -> float:
|
||||||
|
return round(
|
||||||
|
float(host_decision.get("model_duration_seconds") or 0)
|
||||||
|
+ float(app_decision.get("model_duration_seconds") or 0),
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _model_errors(self, *parts: dict[str, Any]) -> list[str]:
|
||||||
|
errors: list[str] = []
|
||||||
|
for part in parts:
|
||||||
|
error = str(part.get("model_error") or part.get("error") or "").strip()
|
||||||
|
if error and error not in errors:
|
||||||
|
errors.append(error)
|
||||||
|
return errors
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
import webbrowser
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urldefrag, urljoin, urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||||
|
UNVERIFIED_HTTPS_CONTEXT = ssl._create_unverified_context()
|
||||||
|
MAX_HTML_BYTES = 1_500_000
|
||||||
|
MAX_CHILD_TASKS = 8
|
||||||
|
|
||||||
|
|
||||||
|
def network_error_message(exc: BaseException, timeout_seconds: float) -> str:
|
||||||
|
reason = getattr(exc, "reason", exc)
|
||||||
|
if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError):
|
||||||
|
return f"timed out after {timeout_seconds:g}s"
|
||||||
|
return str(reason) or type(reason).__name__
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolResult:
|
||||||
|
ok: bool
|
||||||
|
result: dict[str, Any] = field(default_factory=dict)
|
||||||
|
error: str = ""
|
||||||
|
duration_seconds: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def clean_text(value: Any, max_length: int = 160) -> str:
|
||||||
|
text = html.unescape(re.sub(r"\s+", " ", str(value or ""))).strip()
|
||||||
|
return text[:max_length].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_body(raw: bytes, content_type: str) -> str:
|
||||||
|
charset = "utf-8"
|
||||||
|
match = re.search(r"charset=([^;\s]+)", content_type or "", re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
charset = match.group(1).strip("\"'")
|
||||||
|
for candidate in [charset, "utf-8", "gbk", "latin-1"]:
|
||||||
|
try:
|
||||||
|
return raw.decode(candidate, errors="replace")
|
||||||
|
except LookupError:
|
||||||
|
continue
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title(raw: bytes, content_type: str) -> str:
|
||||||
|
text = decode_body(raw, content_type)
|
||||||
|
match = TITLE_RE.search(text)
|
||||||
|
if not match:
|
||||||
|
return ""
|
||||||
|
return clean_text(match.group(1), 120)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if text and "://" not in text:
|
||||||
|
text = f"http://{text}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def origin_tuple(url: str) -> tuple[str, str, int]:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
return parsed.scheme, (parsed.hostname or "").lower(), port
|
||||||
|
|
||||||
|
|
||||||
|
def same_origin(base_url: str, candidate_url: str) -> bool:
|
||||||
|
try:
|
||||||
|
return origin_tuple(base_url) == origin_tuple(candidate_url)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def safe_join_url(base_url: str, href: str) -> str | None:
|
||||||
|
text = str(href or "").strip()
|
||||||
|
if not text or text.startswith(("#", "javascript:", "mailto:", "tel:", "data:")):
|
||||||
|
return None
|
||||||
|
joined = urljoin(base_url, text)
|
||||||
|
joined, _fragment = urldefrag(joined)
|
||||||
|
parsed = urlparse(joined)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
return None
|
||||||
|
if not same_origin(base_url, joined):
|
||||||
|
return None
|
||||||
|
return joined
|
||||||
|
|
||||||
|
|
||||||
|
def stable_id(*parts: Any, prefix: str = "agent") -> str:
|
||||||
|
raw = "|".join(str(part or "") for part in parts)
|
||||||
|
return f"{prefix}-{hashlib.sha1(raw.encode('utf-8')).hexdigest()[:12]}"
|
||||||
|
|
||||||
|
|
||||||
|
def risk_for_text(text: str) -> str:
|
||||||
|
lowered = text.lower()
|
||||||
|
high = ["delete", "remove", "drop", "pay", "refund", "logout", "删除", "清空", "支付", "付款", "退款", "注销"]
|
||||||
|
medium = ["save", "submit", "create", "update", "edit", "upload", "保存", "提交", "新增", "修改", "上传"]
|
||||||
|
if any(word in lowered for word in high):
|
||||||
|
return "high"
|
||||||
|
if any(word in lowered for word in medium):
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
class WebPageParser(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.title_parts: list[str] = []
|
||||||
|
self.links: list[dict[str, Any]] = []
|
||||||
|
self.forms: list[dict[str, Any]] = []
|
||||||
|
self.buttons: list[dict[str, Any]] = []
|
||||||
|
self._in_title = False
|
||||||
|
self._anchors: list[dict[str, Any]] = []
|
||||||
|
self._form: dict[str, Any] | None = None
|
||||||
|
self._button: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
attr = {name.lower(): value or "" for name, value in attrs}
|
||||||
|
if tag == "title":
|
||||||
|
self._in_title = True
|
||||||
|
elif tag == "a":
|
||||||
|
self._anchors.append({"href": attr.get("href", ""), "text_parts": []})
|
||||||
|
elif tag == "form":
|
||||||
|
self._form = {
|
||||||
|
"action": attr.get("action", ""),
|
||||||
|
"method": (attr.get("method") or "get").upper(),
|
||||||
|
"id": attr.get("id", ""),
|
||||||
|
"name": attr.get("name", ""),
|
||||||
|
"class": attr.get("class", ""),
|
||||||
|
"text_parts": [],
|
||||||
|
"fields": [],
|
||||||
|
}
|
||||||
|
elif self._form is not None and tag in {"input", "select", "textarea"}:
|
||||||
|
self._form["fields"].append(
|
||||||
|
{
|
||||||
|
"tag": tag,
|
||||||
|
"type": (attr.get("type") or tag).lower(),
|
||||||
|
"name": attr.get("name", ""),
|
||||||
|
"id": attr.get("id", ""),
|
||||||
|
"placeholder": attr.get("placeholder", ""),
|
||||||
|
"aria_label": attr.get("aria-label", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif tag == "button":
|
||||||
|
self._button = {
|
||||||
|
"type": attr.get("type", ""),
|
||||||
|
"id": attr.get("id", ""),
|
||||||
|
"name": attr.get("name", ""),
|
||||||
|
"class": attr.get("class", ""),
|
||||||
|
"text_parts": [],
|
||||||
|
"in_form": self._form is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._in_title:
|
||||||
|
self.title_parts.append(data)
|
||||||
|
if self._anchors:
|
||||||
|
self._anchors[-1]["text_parts"].append(data)
|
||||||
|
if self._form is not None:
|
||||||
|
self._form["text_parts"].append(data)
|
||||||
|
if self._button is not None:
|
||||||
|
self._button["text_parts"].append(data)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if tag == "title":
|
||||||
|
self._in_title = False
|
||||||
|
elif tag == "a" and self._anchors:
|
||||||
|
anchor = self._anchors.pop()
|
||||||
|
self.links.append({"href": anchor.get("href", ""), "text": clean_text(" ".join(anchor.get("text_parts") or []), 120)})
|
||||||
|
elif tag == "button" and self._button is not None:
|
||||||
|
button = self._button
|
||||||
|
button["text"] = clean_text(" ".join(button.get("text_parts") or []), 120)
|
||||||
|
self.buttons.append(button)
|
||||||
|
self._button = None
|
||||||
|
elif tag == "form" and self._form is not None:
|
||||||
|
form = self._form
|
||||||
|
form["text"] = clean_text(" ".join(form.get("text_parts") or []), 160)
|
||||||
|
self.forms.append(form)
|
||||||
|
self._form = None
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_login_form(form: dict[str, Any]) -> bool:
|
||||||
|
fields = form.get("fields") or []
|
||||||
|
text = " ".join(
|
||||||
|
[
|
||||||
|
str(form.get("id") or ""),
|
||||||
|
str(form.get("name") or ""),
|
||||||
|
str(form.get("class") or ""),
|
||||||
|
str(form.get("text") or ""),
|
||||||
|
*[
|
||||||
|
" ".join(
|
||||||
|
[
|
||||||
|
str(field.get("type") or ""),
|
||||||
|
str(field.get("name") or ""),
|
||||||
|
str(field.get("id") or ""),
|
||||||
|
str(field.get("placeholder") or ""),
|
||||||
|
str(field.get("aria_label") or ""),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for field in fields
|
||||||
|
],
|
||||||
|
]
|
||||||
|
).lower()
|
||||||
|
return "password" in text or any(keyword in text for keyword in ["login", "signin", "sign in", "登录", "账号", "密码"])
|
||||||
|
|
||||||
|
|
||||||
|
class WebTools:
|
||||||
|
def open_in_browser(self, url: str, wait_seconds: float = 1.5) -> ToolResult:
|
||||||
|
target_url = normalize_url(url)
|
||||||
|
if not target_url:
|
||||||
|
return ToolResult(ok=False, error="Missing target URL.")
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
opened = bool(webbrowser.open(target_url, new=2, autoraise=True))
|
||||||
|
if wait_seconds > 0:
|
||||||
|
time.sleep(min(max(wait_seconds, 0.0), 5.0))
|
||||||
|
return ToolResult(
|
||||||
|
ok=opened,
|
||||||
|
result={"url": target_url, "opened": opened},
|
||||||
|
error="" if opened else "Default browser did not accept the URL.",
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(
|
||||||
|
ok=False,
|
||||||
|
error=str(exc),
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
def observe_page(self, url: str, timeout_seconds: float = 8) -> ToolResult:
|
||||||
|
target_url = normalize_url(url)
|
||||||
|
if not target_url:
|
||||||
|
return ToolResult(ok=False, error="Missing target URL.")
|
||||||
|
|
||||||
|
request = Request(
|
||||||
|
target_url,
|
||||||
|
headers={
|
||||||
|
"Accept": "text/html,application/xhtml+xml,*/*",
|
||||||
|
"User-Agent": "SystemTest-Agent/0.1 WebTools",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout_seconds, context=UNVERIFIED_HTTPS_CONTEXT) as response:
|
||||||
|
raw = response.read(MAX_HTML_BYTES)
|
||||||
|
status = response.status
|
||||||
|
final_url = response.geturl()
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
except HTTPError as exc:
|
||||||
|
raw = exc.read(MAX_HTML_BYTES)
|
||||||
|
status = exc.code
|
||||||
|
final_url = exc.geturl()
|
||||||
|
content_type = exc.headers.get("Content-Type", "")
|
||||||
|
except (URLError, TimeoutError, OSError) as exc:
|
||||||
|
return ToolResult(
|
||||||
|
ok=False,
|
||||||
|
error=f"Target URL is not reachable: {network_error_message(exc, timeout_seconds)}",
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = decode_body(raw, content_type)
|
||||||
|
parser = WebPageParser()
|
||||||
|
try:
|
||||||
|
parser.feed(text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
title = clean_text(" ".join(parser.title_parts), 120) or extract_title(raw, content_type)
|
||||||
|
duration = round(time.monotonic() - started, 3)
|
||||||
|
has_login_form = any(looks_like_login_form(form) for form in parser.forms)
|
||||||
|
auth_challenge = status in {401, 403}
|
||||||
|
return ToolResult(
|
||||||
|
ok=status < 500,
|
||||||
|
duration_seconds=duration,
|
||||||
|
result={
|
||||||
|
"url": target_url,
|
||||||
|
"final_url": final_url,
|
||||||
|
"http_status": status,
|
||||||
|
"content_type": content_type,
|
||||||
|
"page_title": title,
|
||||||
|
"elapsed_seconds": duration,
|
||||||
|
"links": parser.links[:50],
|
||||||
|
"forms": parser.forms[:20],
|
||||||
|
"buttons": parser.buttons[:50],
|
||||||
|
"requires_auth": auth_challenge,
|
||||||
|
"auth_challenge": auth_challenge,
|
||||||
|
"has_login_form": has_login_form,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def discover_child_tasks(
|
||||||
|
self,
|
||||||
|
parent_task: dict[str, Any],
|
||||||
|
observation: dict[str, Any],
|
||||||
|
*,
|
||||||
|
max_children: int = MAX_CHILD_TASKS,
|
||||||
|
max_depth: int = 2,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
payload = parent_task.get("payload") if isinstance(parent_task.get("payload"), dict) else {}
|
||||||
|
parent_external_id = str(parent_task.get("external_id") or parent_task.get("id") or "")
|
||||||
|
root_external_id = str(parent_task.get("root_external_id") or payload.get("root_external_id") or parent_external_id)
|
||||||
|
parent_depth = int(parent_task.get("depth") or payload.get("depth") or 0)
|
||||||
|
discovery_max_depth = int(payload.get("discovery_max_depth") or max_depth)
|
||||||
|
child_depth = parent_depth + 1
|
||||||
|
if child_depth > discovery_max_depth:
|
||||||
|
return []
|
||||||
|
|
||||||
|
page_url = observation.get("final_url") or observation.get("url") or payload.get("target_url") or ""
|
||||||
|
children: list[dict[str, Any]] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
|
||||||
|
for link in observation.get("links") or []:
|
||||||
|
if len(children) >= max_children:
|
||||||
|
break
|
||||||
|
href = safe_join_url(page_url, str(link.get("href") or ""))
|
||||||
|
if not href or href in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(href)
|
||||||
|
label = clean_text(link.get("text"), 80) or urlparse(href).path.strip("/") or href
|
||||||
|
risk = risk_for_text(label)
|
||||||
|
children.append(
|
||||||
|
self._child_task(
|
||||||
|
parent_external_id,
|
||||||
|
root_external_id,
|
||||||
|
child_depth,
|
||||||
|
label,
|
||||||
|
f"探索页面:{label}",
|
||||||
|
f"访问并检查页面“{label}”是否可达,记录标题、状态码和可交互入口。",
|
||||||
|
href,
|
||||||
|
"module",
|
||||||
|
risk,
|
||||||
|
"html_link",
|
||||||
|
discovery_max_depth,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for index, form in enumerate(observation.get("forms") or [], start=1):
|
||||||
|
if len(children) >= max_children:
|
||||||
|
break
|
||||||
|
action_url = safe_join_url(page_url, str(form.get("action") or "")) or page_url
|
||||||
|
is_login = looks_like_login_form(form)
|
||||||
|
label = "登录表单" if is_login else clean_text(form.get("name") or form.get("id") or form.get("text"), 60) or f"表单 {index}"
|
||||||
|
risk = "medium" if is_login else risk_for_text(label)
|
||||||
|
child = self._child_task(
|
||||||
|
parent_external_id,
|
||||||
|
root_external_id,
|
||||||
|
child_depth,
|
||||||
|
label,
|
||||||
|
f"识别功能:{label}",
|
||||||
|
f"分析{label}的输入项与提交入口;P0 只确认元素存在,不执行真实提交。",
|
||||||
|
action_url,
|
||||||
|
"feature",
|
||||||
|
risk,
|
||||||
|
"html_form",
|
||||||
|
discovery_max_depth,
|
||||||
|
)
|
||||||
|
child["payload"]["preconditions"] = ["需要测试账号/密码"] if is_login else []
|
||||||
|
children.append(child)
|
||||||
|
|
||||||
|
for button in observation.get("buttons") or []:
|
||||||
|
if len(children) >= max_children:
|
||||||
|
break
|
||||||
|
label = clean_text(button.get("text") or button.get("name") or button.get("id"), 60)
|
||||||
|
if not label:
|
||||||
|
continue
|
||||||
|
risk = risk_for_text(label)
|
||||||
|
child = self._child_task(
|
||||||
|
parent_external_id,
|
||||||
|
root_external_id,
|
||||||
|
child_depth,
|
||||||
|
label,
|
||||||
|
f"识别入口:{label}",
|
||||||
|
f"确认入口“{label}”存在;高风险入口只记录,不执行破坏性动作。",
|
||||||
|
page_url,
|
||||||
|
"feature",
|
||||||
|
risk,
|
||||||
|
"html_button",
|
||||||
|
discovery_max_depth,
|
||||||
|
)
|
||||||
|
if risk == "high":
|
||||||
|
child["failure_policy"] = "pause"
|
||||||
|
children.append(child)
|
||||||
|
|
||||||
|
unique: dict[str, dict[str, Any]] = {}
|
||||||
|
for child in children:
|
||||||
|
unique.setdefault(str(child["external_id"]), child)
|
||||||
|
return list(unique.values())[:max_children]
|
||||||
|
|
||||||
|
def _child_task(
|
||||||
|
self,
|
||||||
|
parent_external_id: str,
|
||||||
|
root_external_id: str,
|
||||||
|
depth: int,
|
||||||
|
module: str,
|
||||||
|
title: str,
|
||||||
|
objective: str,
|
||||||
|
target_url: str,
|
||||||
|
level: str,
|
||||||
|
risk: str,
|
||||||
|
source: str,
|
||||||
|
discovery_max_depth: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
external_id = stable_id(root_external_id, parent_external_id, source, target_url, module)
|
||||||
|
payload = {
|
||||||
|
"task_type": "smart_discovery_p0",
|
||||||
|
"instruction": objective,
|
||||||
|
"objective": objective,
|
||||||
|
"target_url": target_url,
|
||||||
|
"source": source,
|
||||||
|
"level": level,
|
||||||
|
"depth": depth,
|
||||||
|
"risk_level": risk,
|
||||||
|
"allow_child_discovery": level == "module" and depth < discovery_max_depth,
|
||||||
|
"discovery_max_depth": discovery_max_depth,
|
||||||
|
"open_in_browser": True,
|
||||||
|
"capture_screenshot": True,
|
||||||
|
"hierarchy": {
|
||||||
|
"id": external_id,
|
||||||
|
"parent_id": parent_external_id,
|
||||||
|
"root_id": root_external_id,
|
||||||
|
"depth": depth,
|
||||||
|
"level": level,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"external_id": external_id,
|
||||||
|
"title": title,
|
||||||
|
"module": module,
|
||||||
|
"parent_external_id": parent_external_id,
|
||||||
|
"root_external_id": root_external_id,
|
||||||
|
"depth": depth,
|
||||||
|
"task_level": level,
|
||||||
|
"risk_level": risk,
|
||||||
|
"priority": 40 + depth * 10 + (40 if risk == "high" else 20 if risk == "medium" else 0),
|
||||||
|
"failure_policy": "pause" if risk == "high" else "skip",
|
||||||
|
"max_retries": 2,
|
||||||
|
"timeout_seconds": 60,
|
||||||
|
"tags": ["agent-discovered", "p0"],
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GuiTools:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._user32 = ctypes.windll.user32 if self.available() else None
|
||||||
|
self._enum_proc_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM) if self.available() else None
|
||||||
|
if self._user32 and self._enum_proc_type:
|
||||||
|
self._user32.EnumWindows.argtypes = [ctypes.c_void_p, ctypes.wintypes.LPARAM]
|
||||||
|
self._user32.EnumWindows.restype = ctypes.c_bool
|
||||||
|
self._user32.EnumChildWindows.argtypes = [ctypes.wintypes.HWND, ctypes.c_void_p, ctypes.wintypes.LPARAM]
|
||||||
|
self._user32.EnumChildWindows.restype = ctypes.c_bool
|
||||||
|
|
||||||
|
def available(self) -> bool:
|
||||||
|
return platform.system() == "Windows" and hasattr(ctypes, "windll")
|
||||||
|
|
||||||
|
def backend_status(self) -> dict[str, Any]:
|
||||||
|
if not self.available():
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"backend": "ctypes-win32",
|
||||||
|
"reason": "Windows GUI APIs are unavailable on this platform.",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"backend": "ctypes-win32",
|
||||||
|
"capabilities": [
|
||||||
|
"list_windows",
|
||||||
|
"inspect_window_controls",
|
||||||
|
"capture_desktop",
|
||||||
|
"capture_window",
|
||||||
|
"focus_window",
|
||||||
|
"click_window_point",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_windows(self, limit: int = 80) -> ToolResult:
|
||||||
|
started = time.monotonic()
|
||||||
|
if not self.available():
|
||||||
|
return ToolResult(ok=False, error=self.backend_status()["reason"])
|
||||||
|
try:
|
||||||
|
windows = self._enum_windows(limit=limit)
|
||||||
|
return ToolResult(
|
||||||
|
ok=True,
|
||||||
|
result={"windows": windows, "backend": self.backend_status()},
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
|
||||||
|
|
||||||
|
def inspect_window(self, window_id: str, limit: int = 220) -> ToolResult:
|
||||||
|
started = time.monotonic()
|
||||||
|
if not self.available():
|
||||||
|
return ToolResult(ok=False, error=self.backend_status()["reason"])
|
||||||
|
try:
|
||||||
|
hwnd = self._parse_hwnd(window_id)
|
||||||
|
if not self._user32.IsWindow(hwnd):
|
||||||
|
return ToolResult(ok=False, error=f"Window not found: {window_id}")
|
||||||
|
return ToolResult(
|
||||||
|
ok=True,
|
||||||
|
result={
|
||||||
|
"window": self._window_info(hwnd),
|
||||||
|
"controls": self._enum_child_windows(hwnd, limit=limit),
|
||||||
|
"backend": self.backend_status(),
|
||||||
|
},
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
|
||||||
|
|
||||||
|
def capture_desktop(self, save_dir: Path, prefix: str = "desktop") -> ToolResult:
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
from PIL import ImageGrab
|
||||||
|
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
image = ImageGrab.grab(all_screens=True)
|
||||||
|
path = save_dir / f"{prefix}_{int(time.time() * 1000)}.png"
|
||||||
|
image.save(path)
|
||||||
|
return ToolResult(
|
||||||
|
ok=True,
|
||||||
|
result={"image_path": str(path), "width": image.width, "height": image.height},
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
|
||||||
|
|
||||||
|
def capture_window(self, window_id: str, save_dir: Path, prefix: str = "window") -> ToolResult:
|
||||||
|
started = time.monotonic()
|
||||||
|
if not self.available():
|
||||||
|
return ToolResult(ok=False, error=self.backend_status()["reason"])
|
||||||
|
try:
|
||||||
|
from PIL import ImageGrab
|
||||||
|
|
||||||
|
hwnd = self._parse_hwnd(window_id)
|
||||||
|
if not self._user32.IsWindow(hwnd):
|
||||||
|
return ToolResult(ok=False, error=f"Window not found: {window_id}")
|
||||||
|
rect = self._rect(hwnd)
|
||||||
|
if rect["width"] <= 0 or rect["height"] <= 0:
|
||||||
|
return ToolResult(ok=False, error=f"Window has invalid bounds: {window_id}")
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
image = ImageGrab.grab(bbox=(rect["left"], rect["top"], rect["right"], rect["bottom"]))
|
||||||
|
path = save_dir / f"{prefix}_{window_id}_{int(time.time() * 1000)}.png"
|
||||||
|
image.save(path)
|
||||||
|
return ToolResult(
|
||||||
|
ok=True,
|
||||||
|
result={
|
||||||
|
"image_path": str(path),
|
||||||
|
"window": self._window_info(hwnd),
|
||||||
|
"width": image.width,
|
||||||
|
"height": image.height,
|
||||||
|
},
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
|
||||||
|
|
||||||
|
def focus_window(self, window_id: str) -> ToolResult:
|
||||||
|
started = time.monotonic()
|
||||||
|
if not self.available():
|
||||||
|
return ToolResult(ok=False, error=self.backend_status()["reason"])
|
||||||
|
try:
|
||||||
|
hwnd = self._parse_hwnd(window_id)
|
||||||
|
if not self._user32.IsWindow(hwnd):
|
||||||
|
return ToolResult(ok=False, error=f"Window not found: {window_id}")
|
||||||
|
self._user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||||||
|
ok = bool(self._user32.SetForegroundWindow(hwnd))
|
||||||
|
return ToolResult(
|
||||||
|
ok=ok,
|
||||||
|
result={"window": self._window_info(hwnd), "focused": ok},
|
||||||
|
error="" if ok else "SetForegroundWindow returned false.",
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
|
||||||
|
|
||||||
|
def click_window_point(self, window_id: str, x: float, y: float, normalized: bool = True) -> ToolResult:
|
||||||
|
started = time.monotonic()
|
||||||
|
if not self.available():
|
||||||
|
return ToolResult(ok=False, error=self.backend_status()["reason"])
|
||||||
|
try:
|
||||||
|
hwnd = self._parse_hwnd(window_id)
|
||||||
|
if not self._user32.IsWindow(hwnd):
|
||||||
|
return ToolResult(ok=False, error=f"Window not found: {window_id}")
|
||||||
|
rect = self._rect(hwnd)
|
||||||
|
if normalized:
|
||||||
|
target_x = int(rect["left"] + max(0.0, min(1.0, float(x))) * rect["width"])
|
||||||
|
target_y = int(rect["top"] + max(0.0, min(1.0, float(y))) * rect["height"])
|
||||||
|
else:
|
||||||
|
target_x = int(x)
|
||||||
|
target_y = int(y)
|
||||||
|
self.focus_window(window_id)
|
||||||
|
self._user32.SetCursorPos(target_x, target_y)
|
||||||
|
self._user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN
|
||||||
|
self._user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP
|
||||||
|
return ToolResult(
|
||||||
|
ok=True,
|
||||||
|
result={"window_id": str(window_id), "x": target_x, "y": target_y, "normalized": normalized},
|
||||||
|
duration_seconds=round(time.monotonic() - started, 3),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ToolResult(ok=False, error=str(exc), duration_seconds=round(time.monotonic() - started, 3))
|
||||||
|
|
||||||
|
def _parse_hwnd(self, window_id: str) -> int:
|
||||||
|
text = str(window_id).strip()
|
||||||
|
if text.lower().startswith("0x"):
|
||||||
|
return int(text, 16)
|
||||||
|
return int(text)
|
||||||
|
|
||||||
|
def _window_text(self, hwnd: int, max_length: int = 512) -> str:
|
||||||
|
length = min(max(self._user32.GetWindowTextLengthW(hwnd), 0) + 1, max_length)
|
||||||
|
buffer = ctypes.create_unicode_buffer(length)
|
||||||
|
self._user32.GetWindowTextW(hwnd, buffer, length)
|
||||||
|
return clean_text(buffer.value, 200)
|
||||||
|
|
||||||
|
def _class_name(self, hwnd: int) -> str:
|
||||||
|
buffer = ctypes.create_unicode_buffer(256)
|
||||||
|
self._user32.GetClassNameW(hwnd, buffer, 256)
|
||||||
|
return clean_text(buffer.value, 120)
|
||||||
|
|
||||||
|
def _rect(self, hwnd: int) -> dict[str, int]:
|
||||||
|
rect = ctypes.wintypes.RECT()
|
||||||
|
self._user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||||
|
return {
|
||||||
|
"left": int(rect.left),
|
||||||
|
"top": int(rect.top),
|
||||||
|
"right": int(rect.right),
|
||||||
|
"bottom": int(rect.bottom),
|
||||||
|
"width": max(0, int(rect.right - rect.left)),
|
||||||
|
"height": max(0, int(rect.bottom - rect.top)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _process_id(self, hwnd: int) -> int:
|
||||||
|
pid = ctypes.wintypes.DWORD()
|
||||||
|
self._user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
return int(pid.value)
|
||||||
|
|
||||||
|
def _window_info(self, hwnd: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(int(hwnd)),
|
||||||
|
"hex_id": hex(int(hwnd)),
|
||||||
|
"title": self._window_text(hwnd),
|
||||||
|
"class_name": self._class_name(hwnd),
|
||||||
|
"process_id": self._process_id(hwnd),
|
||||||
|
"visible": bool(self._user32.IsWindowVisible(hwnd)),
|
||||||
|
"enabled": bool(self._user32.IsWindowEnabled(hwnd)),
|
||||||
|
"minimized": bool(self._user32.IsIconic(hwnd)),
|
||||||
|
"rect": self._rect(hwnd),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _enum_windows(self, limit: int = 80) -> list[dict[str, Any]]:
|
||||||
|
windows: list[dict[str, Any]] = []
|
||||||
|
enum_proc = self._enum_proc_type
|
||||||
|
|
||||||
|
@enum_proc
|
||||||
|
def callback(hwnd: int, _lparam: int) -> bool:
|
||||||
|
if len(windows) >= limit:
|
||||||
|
return False
|
||||||
|
if not self._user32.IsWindowVisible(hwnd):
|
||||||
|
return True
|
||||||
|
info = self._window_info(hwnd)
|
||||||
|
rect = info["rect"]
|
||||||
|
if rect["width"] < 40 or rect["height"] < 40:
|
||||||
|
return True
|
||||||
|
if not info["title"]:
|
||||||
|
return True
|
||||||
|
if info["title"] or info["class_name"]:
|
||||||
|
info["index"] = len(windows) + 1
|
||||||
|
windows.append(info)
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._user32.EnumWindows(ctypes.cast(callback, ctypes.c_void_p), ctypes.wintypes.LPARAM(0))
|
||||||
|
return windows
|
||||||
|
|
||||||
|
def _enum_child_windows(self, hwnd: int, limit: int = 220) -> list[dict[str, Any]]:
|
||||||
|
controls: list[dict[str, Any]] = []
|
||||||
|
enum_proc = self._enum_proc_type
|
||||||
|
|
||||||
|
@enum_proc
|
||||||
|
def callback(child_hwnd: int, _lparam: int) -> bool:
|
||||||
|
if len(controls) >= limit:
|
||||||
|
return False
|
||||||
|
rect = self._rect(child_hwnd)
|
||||||
|
if rect["width"] <= 0 or rect["height"] <= 0:
|
||||||
|
return True
|
||||||
|
text = self._window_text(child_hwnd)
|
||||||
|
class_name = self._class_name(child_hwnd)
|
||||||
|
if not text and not class_name:
|
||||||
|
return True
|
||||||
|
controls.append(
|
||||||
|
{
|
||||||
|
"id": str(int(child_hwnd)),
|
||||||
|
"hex_id": hex(int(child_hwnd)),
|
||||||
|
"index": len(controls) + 1,
|
||||||
|
"text": text,
|
||||||
|
"class_name": class_name,
|
||||||
|
"visible": bool(self._user32.IsWindowVisible(child_hwnd)),
|
||||||
|
"enabled": bool(self._user32.IsWindowEnabled(child_hwnd)),
|
||||||
|
"rect": rect,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._user32.EnumChildWindows(hwnd, ctypes.cast(callback, ctypes.c_void_p), ctypes.wintypes.LPARAM(0))
|
||||||
|
return controls
|
||||||
|
|
||||||
|
|
||||||
|
class AgentToolRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.web = WebTools()
|
||||||
|
self.gui = GuiTools()
|
||||||
|
|
||||||
|
def descriptions(self) -> list[dict[str, Any]]:
|
||||||
|
gui_status = self.gui.backend_status()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "open_in_browser",
|
||||||
|
"scope": "web",
|
||||||
|
"available": True,
|
||||||
|
"description": "Open the target URL in the controlled device default browser so the operator can see the test start.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "observe_web_page",
|
||||||
|
"scope": "web",
|
||||||
|
"available": True,
|
||||||
|
"description": "Open a web page, read status/title, and list links/forms/buttons without side effects.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "discover_web_subtasks",
|
||||||
|
"scope": "web",
|
||||||
|
"available": True,
|
||||||
|
"description": "Create hierarchy-aware P0 child tasks from observed links/forms/buttons.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_windows",
|
||||||
|
"scope": "gui",
|
||||||
|
"available": gui_status["available"],
|
||||||
|
"description": "List visible Windows desktop application windows using a dependency-free ctypes Win32 backend.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inspect_window_controls",
|
||||||
|
"scope": "gui",
|
||||||
|
"available": gui_status["available"],
|
||||||
|
"description": "Enumerate Win32 child controls for a selected window.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "capture_desktop",
|
||||||
|
"scope": "gui",
|
||||||
|
"available": True,
|
||||||
|
"description": "Capture the desktop screenshot with PIL ImageGrab and save it as local evidence.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "capture_window",
|
||||||
|
"scope": "gui",
|
||||||
|
"available": gui_status["available"],
|
||||||
|
"description": "Capture a selected window region and save it as local evidence.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "focus_window",
|
||||||
|
"scope": "gui",
|
||||||
|
"available": gui_status["available"],
|
||||||
|
"description": "Bring a window to foreground. This is an action tool and should be used only when the task allows it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "click_window_point",
|
||||||
|
"scope": "gui",
|
||||||
|
"available": gui_status["available"],
|
||||||
|
"description": "Click a normalized or absolute point in a selected window. This is guarded and not used by default in P0 observation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "no_action",
|
||||||
|
"scope": "agent",
|
||||||
|
"available": True,
|
||||||
|
"description": "Finish, fail, or wait for user input without executing a UI action.",
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
if not path.exists():
|
||||||
|
return values
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key:
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _get_env(name: str, default: str, file_values: dict[str, str]) -> str:
|
||||||
|
return os.environ.get(name, file_values.get(name, default))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: str, default: float) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: str, default: bool) -> bool:
|
||||||
|
normalized = str(value).strip().lower()
|
||||||
|
if normalized in {"1", "true", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if normalized in {"0", "false", "no", "off"}:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int_list(value: str, default: list[int]) -> list[int]:
|
||||||
|
ports: list[int] = []
|
||||||
|
for part in str(value).replace(";", ",").split(","):
|
||||||
|
item = part.strip()
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = int(item)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if 1 <= port <= 65535:
|
||||||
|
ports.append(port)
|
||||||
|
return ports or default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(value: str) -> Path:
|
||||||
|
path = Path(value)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return ROOT_DIR / path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentConfig:
|
||||||
|
root_dir: Path
|
||||||
|
controller_url: str
|
||||||
|
controller_discovery_enabled: bool
|
||||||
|
controller_discovery_ports: list[int]
|
||||||
|
controller_discovery_timeout_seconds: float
|
||||||
|
state_path: Path
|
||||||
|
model_config_path: Path
|
||||||
|
runtime_state_path: Path
|
||||||
|
memory_path: Path
|
||||||
|
display_name: str
|
||||||
|
deployment_type: str
|
||||||
|
heartbeat_interval_seconds: float
|
||||||
|
task_poll_interval_seconds: float
|
||||||
|
controller_retry_interval_seconds: float
|
||||||
|
request_timeout_seconds: float
|
||||||
|
local_service_enabled: bool
|
||||||
|
local_service_host: str
|
||||||
|
local_service_port: int
|
||||||
|
runtime_command: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "AgentConfig":
|
||||||
|
file_values = _load_env_file(ROOT_DIR / ".env")
|
||||||
|
return cls(
|
||||||
|
root_dir=ROOT_DIR,
|
||||||
|
controller_url=_get_env("CONTROLLER_URL", "auto", file_values).rstrip("/"),
|
||||||
|
controller_discovery_enabled=_as_bool(
|
||||||
|
_get_env("AGENT_CONTROLLER_DISCOVERY_ENABLED", "true", file_values),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
controller_discovery_ports=_as_int_list(
|
||||||
|
_get_env("AGENT_CONTROLLER_DISCOVERY_PORTS", "8001,8000", file_values),
|
||||||
|
[8001, 8000],
|
||||||
|
),
|
||||||
|
controller_discovery_timeout_seconds=_as_float(
|
||||||
|
_get_env("AGENT_CONTROLLER_DISCOVERY_TIMEOUT_SECONDS", "0.35", file_values),
|
||||||
|
0.35,
|
||||||
|
),
|
||||||
|
state_path=_resolve_path(_get_env("AGENT_STATE_PATH", "data/agent_state.json", file_values)),
|
||||||
|
model_config_path=_resolve_path(
|
||||||
|
_get_env("AGENT_MODEL_CONFIG_PATH", "data/model_config.json", file_values)
|
||||||
|
),
|
||||||
|
runtime_state_path=_resolve_path(
|
||||||
|
_get_env("AGENT_RUNTIME_STATE_PATH", "data/runtime_state.json", file_values)
|
||||||
|
),
|
||||||
|
memory_path=_resolve_path(
|
||||||
|
_get_env("AGENT_MEMORY_PATH", "data/agent_memory.json", file_values)
|
||||||
|
),
|
||||||
|
display_name=_get_env("AGENT_DISPLAY_NAME", "", file_values),
|
||||||
|
deployment_type=_get_env("AGENT_DEPLOYMENT_TYPE", "auto", file_values),
|
||||||
|
heartbeat_interval_seconds=_as_float(
|
||||||
|
_get_env("AGENT_HEARTBEAT_INTERVAL_SECONDS", "1", file_values),
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
task_poll_interval_seconds=_as_float(
|
||||||
|
_get_env("AGENT_TASK_POLL_INTERVAL_SECONDS", "5", file_values),
|
||||||
|
5.0,
|
||||||
|
),
|
||||||
|
controller_retry_interval_seconds=_as_float(
|
||||||
|
_get_env("AGENT_CONTROLLER_RETRY_INTERVAL_SECONDS", "3", file_values),
|
||||||
|
3.0,
|
||||||
|
),
|
||||||
|
request_timeout_seconds=_as_float(
|
||||||
|
_get_env("AGENT_REQUEST_TIMEOUT_SECONDS", "5", file_values),
|
||||||
|
5.0,
|
||||||
|
),
|
||||||
|
local_service_enabled=_as_bool(
|
||||||
|
_get_env("AGENT_LOCAL_SERVICE_ENABLED", "true", file_values),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
local_service_host=_get_env(
|
||||||
|
"AGENT_LOCAL_SERVICE_HOST",
|
||||||
|
_get_env("LOG_WEB_HOST", "0.0.0.0", file_values),
|
||||||
|
file_values,
|
||||||
|
),
|
||||||
|
local_service_port=_as_int(
|
||||||
|
_get_env(
|
||||||
|
"AGENT_LOCAL_SERVICE_PORT",
|
||||||
|
_get_env("LOG_WEB_PORT", "18081", file_values),
|
||||||
|
file_values,
|
||||||
|
),
|
||||||
|
18081,
|
||||||
|
),
|
||||||
|
runtime_command=_get_env("AGENT_RUNTIME_COMMAND", "", file_values),
|
||||||
|
)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
DISCOVERY_PATH = "/api/v1/discovery"
|
||||||
|
AUTO_VALUES = {"", "auto", "discover", "lan"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_ipv4(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return ip.version == 4 and ip.is_private and not ip.is_loopback
|
||||||
|
|
||||||
|
|
||||||
|
def _local_private_ipv4s() -> list[str]:
|
||||||
|
addresses: set[str] = set()
|
||||||
|
try:
|
||||||
|
for value in socket.gethostbyname_ex(socket.gethostname())[2]:
|
||||||
|
if _is_private_ipv4(value):
|
||||||
|
addresses.add(value)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# UDP connect does not send packets; it reveals the chosen outbound address.
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
|
sock.connect(("8.8.8.8", 80))
|
||||||
|
value = sock.getsockname()[0]
|
||||||
|
if _is_private_ipv4(value):
|
||||||
|
addresses.add(value)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return sorted(addresses)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_hosts() -> list[str]:
|
||||||
|
hosts: set[str] = {"127.0.0.1"}
|
||||||
|
for address in _local_private_ipv4s():
|
||||||
|
hosts.add(address)
|
||||||
|
network = ipaddress.ip_network(f"{address}/24", strict=False)
|
||||||
|
for host in network.hosts():
|
||||||
|
hosts.add(str(host))
|
||||||
|
return sorted(hosts, key=lambda value: tuple(int(part) for part in value.split(".")))
|
||||||
|
|
||||||
|
|
||||||
|
def _probe(base_url: str, timeout_seconds: float) -> str | None:
|
||||||
|
url = f"{base_url.rstrip('/')}{DISCOVERY_PATH}"
|
||||||
|
request = Request(url, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout_seconds) as response:
|
||||||
|
body = response.read()
|
||||||
|
except (OSError, URLError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if payload.get("service") != "Agent-S-Controller":
|
||||||
|
return None
|
||||||
|
discovered = str(payload.get("base_url") or base_url).rstrip("/")
|
||||||
|
return discovered or None
|
||||||
|
|
||||||
|
|
||||||
|
def discover_controller_base_url(
|
||||||
|
ports: list[int],
|
||||||
|
timeout_seconds: float,
|
||||||
|
workers: int = 96,
|
||||||
|
log=print,
|
||||||
|
) -> str | None:
|
||||||
|
hosts = _candidate_hosts()
|
||||||
|
attempts = [(host, port) for port in ports for host in hosts]
|
||||||
|
if not attempts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
log(
|
||||||
|
"[agent] discovering controller on "
|
||||||
|
+ f"{len(hosts)} hosts x ports {', '.join(str(p) for p in ports)} ..."
|
||||||
|
)
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=min(workers, len(attempts))) as executor:
|
||||||
|
future_map = {
|
||||||
|
executor.submit(_probe, f"http://{host}:{port}", timeout_seconds): (host, port)
|
||||||
|
for host, port in attempts
|
||||||
|
}
|
||||||
|
for future in concurrent.futures.as_completed(future_map):
|
||||||
|
found = future.result()
|
||||||
|
if found:
|
||||||
|
host, port = future_map[future]
|
||||||
|
log(f"[agent] discovered controller at {found} ({host}:{port})")
|
||||||
|
for pending in future_map:
|
||||||
|
pending.cancel()
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_controller_base_url(
|
||||||
|
configured_base_url: str,
|
||||||
|
discovery_enabled: bool,
|
||||||
|
discovery_ports: list[int],
|
||||||
|
timeout_seconds: float,
|
||||||
|
log=print,
|
||||||
|
) -> str:
|
||||||
|
configured = (configured_base_url or "auto").strip().rstrip("/")
|
||||||
|
if configured.lower() not in AUTO_VALUES:
|
||||||
|
found = _probe(configured, timeout_seconds)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
if not discovery_enabled:
|
||||||
|
return configured
|
||||||
|
log(f"[agent] configured controller is not reachable yet: {configured}")
|
||||||
|
|
||||||
|
if discovery_enabled:
|
||||||
|
found = discover_controller_base_url(
|
||||||
|
ports=discovery_ports,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
log=log,
|
||||||
|
)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
ports = ", ".join(str(port) for port in discovery_ports)
|
||||||
|
raise RuntimeError(
|
||||||
|
"Controller was not found. Start the controller first, allow TCP "
|
||||||
|
f"{ports} through the firewall, or start the agent with "
|
||||||
|
"start_agent.bat http://<controller-lan-ip>:8001"
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HttpClientError(RuntimeError):
|
||||||
|
status: int
|
||||||
|
message: str
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"HTTP {self.status}: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
def _network_error_message(exc: BaseException, timeout: float) -> str:
|
||||||
|
reason = getattr(exc, "reason", exc)
|
||||||
|
if isinstance(exc, TimeoutError) or isinstance(reason, TimeoutError):
|
||||||
|
return f"timed out after {timeout:g}s"
|
||||||
|
return str(reason) or type(reason).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
token: str | None = None,
|
||||||
|
timeout: float = 5.0,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
body = None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
request = Request(url, data=body, headers=headers, method=method.upper())
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
response_body = response.read()
|
||||||
|
if not response_body:
|
||||||
|
return {}
|
||||||
|
return json.loads(response_body.decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
|
payload_obj = None
|
||||||
|
message = raw or exc.reason
|
||||||
|
try:
|
||||||
|
payload_obj = json.loads(raw)
|
||||||
|
message = payload_obj.get("error") or message
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
raise HttpClientError(exc.code, message, payload_obj) from exc
|
||||||
|
except (URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise HttpClientError(0, _network_error_message(exc, timeout), None) from exc
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
VM_MARKERS = (
|
||||||
|
"virtual",
|
||||||
|
"vmware",
|
||||||
|
"hyper-v",
|
||||||
|
"hyperv",
|
||||||
|
"kvm",
|
||||||
|
"qemu",
|
||||||
|
"xen",
|
||||||
|
"virtualbox",
|
||||||
|
"parallels",
|
||||||
|
"bochs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def host_name() -> str:
|
||||||
|
return socket.gethostname()
|
||||||
|
|
||||||
|
|
||||||
|
def machine_fingerprint() -> str:
|
||||||
|
material = {
|
||||||
|
"host": host_name(),
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"machine": platform.machine(),
|
||||||
|
"mac": f"{uuid.getnode():012x}",
|
||||||
|
}
|
||||||
|
encoded = json.dumps(material, sort_keys=True).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def public_id() -> str:
|
||||||
|
return f"agent-{machine_fingerprint()[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_windows_computer_system() -> dict[str, Any]:
|
||||||
|
command = [
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"Get-CimInstance Win32_ComputerSystem | "
|
||||||
|
"Select-Object Manufacturer,Model,HypervisorPresent | ConvertTo-Json -Compress",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(completed.stdout.strip() or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def deployment_info(override: str) -> dict[str, Any]:
|
||||||
|
normalized = (override or "auto").strip().lower()
|
||||||
|
aliases = {
|
||||||
|
"physical": "physical_machine",
|
||||||
|
"physical_machine": "physical_machine",
|
||||||
|
"box_physical": "physical_machine",
|
||||||
|
"virtual": "virtual_machine",
|
||||||
|
"vm": "virtual_machine",
|
||||||
|
"virtual_machine": "virtual_machine",
|
||||||
|
"box_virtual": "virtual_machine",
|
||||||
|
}
|
||||||
|
if normalized in aliases:
|
||||||
|
return {
|
||||||
|
"deployment_type": aliases[normalized],
|
||||||
|
"detection_reason": f"env:{override}",
|
||||||
|
"system": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
system_data = _read_windows_computer_system() if platform.system().lower() == "windows" else {}
|
||||||
|
manufacturer = str(system_data.get("Manufacturer") or "")
|
||||||
|
model = str(system_data.get("Model") or "")
|
||||||
|
hypervisor_present = bool(system_data.get("HypervisorPresent"))
|
||||||
|
probe_text = f"{manufacturer} {model} {platform.platform()}".lower()
|
||||||
|
is_virtual = hypervisor_present or any(marker in probe_text for marker in VM_MARKERS)
|
||||||
|
return {
|
||||||
|
"deployment_type": "virtual_machine" if is_virtual else "physical_machine",
|
||||||
|
"detection_reason": "auto:virtualization-marker" if is_virtual else "auto:no-virtualization-marker",
|
||||||
|
"system": {
|
||||||
|
"manufacturer": manufacturer,
|
||||||
|
"model": model,
|
||||||
|
"hypervisor_present": hypervisor_present,
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"machine": platform.machine(),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
StatusProvider = Callable[[], dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_bytes(payload: Any) -> bytes:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def make_handler(status_provider: StatusProvider):
|
||||||
|
class AgentLocalServiceHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "SystemTestAgentLocalService/0.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None:
|
||||||
|
sys.stdout.write(
|
||||||
|
"%s - - [%s] %s\n"
|
||||||
|
% (self.client_address[0], self.log_date_time_string(), fmt % args)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_json(self, status: int, payload: Any) -> None:
|
||||||
|
body = _json_bytes(payload)
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_file(self, file_path: Path) -> None:
|
||||||
|
body = file_path.read_bytes()
|
||||||
|
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_screenshot(self, raw_path: str) -> None:
|
||||||
|
status = status_provider()
|
||||||
|
memory_path = ((status.get("agent_core") or {}).get("memory_path") or "")
|
||||||
|
if not memory_path:
|
||||||
|
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Memory path is not available"})
|
||||||
|
return
|
||||||
|
screenshot_root = (Path(memory_path).parent / "screenshots").resolve()
|
||||||
|
requested = Path(raw_path).resolve()
|
||||||
|
try:
|
||||||
|
requested.relative_to(screenshot_root)
|
||||||
|
except ValueError:
|
||||||
|
self._send_json(HTTPStatus.FORBIDDEN, {"ok": False, "error": "Screenshot path is outside evidence directory"})
|
||||||
|
return
|
||||||
|
if not requested.exists() or not requested.is_file():
|
||||||
|
self._send_json(HTTPStatus.NOT_FOUND, {"ok": False, "error": "Screenshot not found"})
|
||||||
|
return
|
||||||
|
self._send_file(requested)
|
||||||
|
|
||||||
|
def do_OPTIONS(self) -> None:
|
||||||
|
self._send_json(HTTPStatus.OK, {"ok": True})
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
if path in {"/", "/health"}:
|
||||||
|
self._send_json(HTTPStatus.OK, status_provider())
|
||||||
|
return
|
||||||
|
if path == "/agent/tools":
|
||||||
|
status = status_provider()
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"tools": ((status.get("agent_core") or {}).get("tools") or []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/agent/memory":
|
||||||
|
status = status_provider()
|
||||||
|
agent_core = status.get("agent_core") or {}
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.OK,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"memory_path": agent_core.get("memory_path"),
|
||||||
|
"memory": agent_core.get("memory"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path == "/agent/screenshot":
|
||||||
|
query = parse_qs(parsed.query)
|
||||||
|
raw_path = (query.get("path") or [""])[0]
|
||||||
|
if not raw_path:
|
||||||
|
self._send_json(HTTPStatus.BAD_REQUEST, {"ok": False, "error": "Missing path"})
|
||||||
|
return
|
||||||
|
self._send_screenshot(raw_path)
|
||||||
|
return
|
||||||
|
self._send_json(
|
||||||
|
HTTPStatus.NOT_FOUND,
|
||||||
|
{"ok": False, "error": "Route not found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return AgentLocalServiceHandler
|
||||||
|
|
||||||
|
|
||||||
|
class LocalService:
|
||||||
|
def __init__(self, host: str, port: int, status_provider: StatusProvider):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self._server = ThreadingHTTPServer((host, port), make_handler(status_provider))
|
||||||
|
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._server.shutdown()
|
||||||
|
self._server.server_close()
|
||||||
|
self._thread.join(timeout=2)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path, default: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return default
|
||||||
|
return data if isinstance(data, dict) else default
|
||||||
|
|
||||||
|
|
||||||
|
def _save_json(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def load_applied_model_config(path: Path) -> dict[str, Any]:
|
||||||
|
return _load_json(path, {"version": 0, "value": {}, "updated_at": None, "applied_at": None})
|
||||||
|
|
||||||
|
|
||||||
|
def save_applied_model_config(path: Path, payload: dict[str, Any]) -> None:
|
||||||
|
_save_json(path, payload)
|
||||||
|
|
||||||
|
|
||||||
|
def public_model_config_summary(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
value = dict(config.get("value") or {})
|
||||||
|
value["api_key"] = ""
|
||||||
|
value["api_key_set"] = bool(value.get("api_key_set") or (config.get("value") or {}).get("api_key"))
|
||||||
|
return {
|
||||||
|
"version": int(config.get("version") or 0),
|
||||||
|
"updated_at": config.get("updated_at"),
|
||||||
|
"applied_at": config.get("applied_at"),
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeRestartManager:
|
||||||
|
def __init__(self, path: Path, command: str = ""):
|
||||||
|
self.path = path
|
||||||
|
self.command = command.strip()
|
||||||
|
self.process: subprocess.Popen | None = None
|
||||||
|
self.state = _load_json(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"generation": 0,
|
||||||
|
"last_restart_at": None,
|
||||||
|
"reason": "",
|
||||||
|
"model_config_version": 0,
|
||||||
|
"mode": "placeholder",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generation(self) -> int:
|
||||||
|
try:
|
||||||
|
return int(self.state.get("generation") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _start_process(self) -> int | None:
|
||||||
|
if not self.command:
|
||||||
|
return None
|
||||||
|
if self.process and self.process.poll() is None:
|
||||||
|
return self.process.pid
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
self.command,
|
||||||
|
cwd=str(self.path.parent.parent),
|
||||||
|
shell=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return self.process.pid
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
if not self.process or self.process.poll() is not None:
|
||||||
|
return
|
||||||
|
self.process.terminate()
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.process.kill()
|
||||||
|
self.process.wait(timeout=5)
|
||||||
|
|
||||||
|
def ensure_started(self, version: int) -> dict[str, Any] | None:
|
||||||
|
if not self.command:
|
||||||
|
return None
|
||||||
|
pid = self._start_process()
|
||||||
|
now = utc_now()
|
||||||
|
self.state = {
|
||||||
|
"generation": self.generation,
|
||||||
|
"last_restart_at": self.state.get("last_restart_at"),
|
||||||
|
"reason": self.state.get("reason") or "startup",
|
||||||
|
"model_config_version": version,
|
||||||
|
"mode": "subprocess",
|
||||||
|
"pid": pid,
|
||||||
|
"command_configured": True,
|
||||||
|
"checked_at": now,
|
||||||
|
}
|
||||||
|
_save_json(self.path, self.state)
|
||||||
|
return self.state
|
||||||
|
|
||||||
|
def restart_for_model_config(self, version: int) -> dict[str, Any]:
|
||||||
|
now = utc_now()
|
||||||
|
self.stop()
|
||||||
|
pid = self._start_process()
|
||||||
|
self.state = {
|
||||||
|
"generation": self.generation + 1,
|
||||||
|
"last_restart_at": now,
|
||||||
|
"reason": f"model_config_v{version}",
|
||||||
|
"model_config_version": version,
|
||||||
|
"mode": "subprocess" if self.command else "placeholder",
|
||||||
|
"pid": pid,
|
||||||
|
"command_configured": bool(self.command),
|
||||||
|
}
|
||||||
|
_save_json(self.path, self.state)
|
||||||
|
return self.state
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .agent_core import AgentSession, AgentToolRegistry
|
||||||
|
from .config import AgentConfig
|
||||||
|
from .controller_discovery import resolve_controller_base_url
|
||||||
|
from .http_client import HttpClientError, request_json
|
||||||
|
from .identity import deployment_info, host_name, machine_fingerprint, public_id
|
||||||
|
from .local_service import LocalService
|
||||||
|
from .model_config import (
|
||||||
|
RuntimeRestartManager,
|
||||||
|
load_applied_model_config,
|
||||||
|
public_model_config_summary,
|
||||||
|
save_applied_model_config,
|
||||||
|
)
|
||||||
|
from .state import load_state, save_state
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def local_ip() -> str:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
|
sock.connect(("8.8.8.8", 80))
|
||||||
|
return sock.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
class ControlledAgent:
|
||||||
|
def __init__(self, config: AgentConfig):
|
||||||
|
self.config = AgentConfig(
|
||||||
|
root_dir=config.root_dir,
|
||||||
|
controller_url=config.controller_url,
|
||||||
|
controller_discovery_enabled=config.controller_discovery_enabled,
|
||||||
|
controller_discovery_ports=config.controller_discovery_ports,
|
||||||
|
controller_discovery_timeout_seconds=config.controller_discovery_timeout_seconds,
|
||||||
|
state_path=config.state_path,
|
||||||
|
model_config_path=config.model_config_path,
|
||||||
|
runtime_state_path=config.runtime_state_path,
|
||||||
|
memory_path=config.memory_path,
|
||||||
|
display_name=config.display_name,
|
||||||
|
deployment_type=config.deployment_type,
|
||||||
|
heartbeat_interval_seconds=config.heartbeat_interval_seconds,
|
||||||
|
task_poll_interval_seconds=config.task_poll_interval_seconds,
|
||||||
|
controller_retry_interval_seconds=config.controller_retry_interval_seconds,
|
||||||
|
request_timeout_seconds=config.request_timeout_seconds,
|
||||||
|
local_service_enabled=config.local_service_enabled,
|
||||||
|
local_service_host=config.local_service_host,
|
||||||
|
local_service_port=config.local_service_port,
|
||||||
|
runtime_command=config.runtime_command,
|
||||||
|
)
|
||||||
|
self.controller_url: str | None = None
|
||||||
|
self.last_connection_error: str | None = None
|
||||||
|
self.controller_retry_count = 0
|
||||||
|
self.runtime_started = False
|
||||||
|
self.registration_confirmed = False
|
||||||
|
self.fingerprint = machine_fingerprint()
|
||||||
|
self.public_id = public_id()
|
||||||
|
self.host = host_name()
|
||||||
|
self.ip = local_ip()
|
||||||
|
self.started_at = utc_now()
|
||||||
|
self.last_heartbeat_at: str | None = None
|
||||||
|
self.last_task_claim_at: str | None = None
|
||||||
|
self.current_status = "idle"
|
||||||
|
self.local_service: LocalService | None = None
|
||||||
|
self.deployment = deployment_info(config.deployment_type)
|
||||||
|
self.display_name = config.display_name or self.host
|
||||||
|
self.state = load_state(config.state_path)
|
||||||
|
self.model_config_state = load_applied_model_config(config.model_config_path)
|
||||||
|
self.runtime_manager = RuntimeRestartManager(config.runtime_state_path, config.runtime_command)
|
||||||
|
self.tool_registry = AgentToolRegistry()
|
||||||
|
print(
|
||||||
|
"[agent] startup config: "
|
||||||
|
f"controller={self.config.controller_url} "
|
||||||
|
f"local_service={self.config.local_service_host}:{self.config.local_service_port} "
|
||||||
|
f"state={self.config.state_path} "
|
||||||
|
f"model_config={self.config.model_config_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
if not self.controller_url:
|
||||||
|
raise RuntimeError("Controller is not connected yet")
|
||||||
|
return f"{self.controller_url}{path}"
|
||||||
|
|
||||||
|
def resolve_controller(self) -> str:
|
||||||
|
resolved = resolve_controller_base_url(
|
||||||
|
configured_base_url=self.config.controller_url,
|
||||||
|
discovery_enabled=self.config.controller_discovery_enabled,
|
||||||
|
discovery_ports=self.config.controller_discovery_ports,
|
||||||
|
timeout_seconds=self.config.controller_discovery_timeout_seconds,
|
||||||
|
)
|
||||||
|
if self.controller_url != resolved:
|
||||||
|
print(f"[agent] controller connected: {resolved}")
|
||||||
|
self.controller_url = resolved
|
||||||
|
self.last_connection_error = None
|
||||||
|
self.controller_retry_count = 0
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def ensure_registered(self) -> None:
|
||||||
|
current_public_id = self.state.get("public_id")
|
||||||
|
has_credentials = self.state.get("agent_id") and self.state.get("token")
|
||||||
|
if has_credentials and current_public_id == self.public_id:
|
||||||
|
if not self.registration_confirmed:
|
||||||
|
print(
|
||||||
|
f"Using saved registration: agent_id={self.state['agent_id']} "
|
||||||
|
f"public_id={self.public_id}"
|
||||||
|
)
|
||||||
|
self.registration_confirmed = True
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"public_id": self.public_id,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"deployment_type": self.deployment["deployment_type"],
|
||||||
|
"machine_fingerprint": self.fingerprint,
|
||||||
|
"reported_host": self.host,
|
||||||
|
"reported_ip": self.ip,
|
||||||
|
"reported_log_web_port": self.config.local_service_port
|
||||||
|
if self.config.local_service_enabled
|
||||||
|
else None,
|
||||||
|
"agent_version": __version__,
|
||||||
|
"metadata": {
|
||||||
|
"detection_reason": self.deployment["detection_reason"],
|
||||||
|
"system": self.deployment["system"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
print(f"Registering controlled agent at {self.controller_url} ...")
|
||||||
|
response = request_json(
|
||||||
|
"POST",
|
||||||
|
self._url("/api/agents/register"),
|
||||||
|
payload,
|
||||||
|
timeout=self.config.request_timeout_seconds,
|
||||||
|
)
|
||||||
|
self.state = {
|
||||||
|
"agent_id": response["agent_id"],
|
||||||
|
"public_id": response["public_id"],
|
||||||
|
"token": response["token"],
|
||||||
|
"registered_at": response["registered_at"],
|
||||||
|
"controller_url": self.controller_url,
|
||||||
|
}
|
||||||
|
save_state(self.config.state_path, self.state)
|
||||||
|
self.registration_confirmed = True
|
||||||
|
if response.get("token_rotated"):
|
||||||
|
print(
|
||||||
|
"Recovered registration with a new token: "
|
||||||
|
f"agent_id={response['agent_id']} state={self.config.state_path}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"Registered: agent_id={response['agent_id']} state={self.config.state_path}")
|
||||||
|
|
||||||
|
def heartbeat_payload(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": self.current_status,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"deployment_type": self.deployment["deployment_type"],
|
||||||
|
"machine_fingerprint": self.fingerprint,
|
||||||
|
"reported_host": self.host,
|
||||||
|
"reported_ip": self.ip,
|
||||||
|
"reported_log_web_port": self.config.local_service_port
|
||||||
|
if self.config.local_service_enabled
|
||||||
|
else None,
|
||||||
|
"agent_version": __version__,
|
||||||
|
"config_revision": int(self.model_config_state.get("version") or 0),
|
||||||
|
"runtime_generation": self.runtime_manager.generation,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"sent_at": utc_now(),
|
||||||
|
"metadata": {
|
||||||
|
"detection_reason": self.deployment["detection_reason"],
|
||||||
|
"system": self.deployment["system"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def send_heartbeat(self) -> None:
|
||||||
|
agent_id = int(self.state["agent_id"])
|
||||||
|
response = request_json(
|
||||||
|
"POST",
|
||||||
|
self._url(f"/api/agents/{agent_id}/heartbeat"),
|
||||||
|
self.heartbeat_payload(),
|
||||||
|
token=str(self.state["token"]),
|
||||||
|
timeout=self.config.request_timeout_seconds,
|
||||||
|
)
|
||||||
|
self.last_heartbeat_at = response.get("received_at")
|
||||||
|
self.apply_model_config_update(response.get("model_config"))
|
||||||
|
print(f"Heartbeat OK: {response.get('received_at')}")
|
||||||
|
|
||||||
|
def apply_model_config_update(self, model_config: Any) -> None:
|
||||||
|
if not isinstance(model_config, dict):
|
||||||
|
return
|
||||||
|
version = int(model_config.get("version") or 0)
|
||||||
|
current_version = int(self.model_config_state.get("version") or 0)
|
||||||
|
if version <= current_version:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
"version": version,
|
||||||
|
"updated_at": model_config.get("updated_at"),
|
||||||
|
"applied_at": utc_now(),
|
||||||
|
"value": model_config.get("value") or {},
|
||||||
|
}
|
||||||
|
save_applied_model_config(self.config.model_config_path, payload)
|
||||||
|
self.model_config_state = payload
|
||||||
|
restart_state = self.runtime_manager.restart_for_model_config(version)
|
||||||
|
print(
|
||||||
|
"[agent] applied model config "
|
||||||
|
f"v{version}; runtime restart generation={restart_state.get('generation')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def ensure_runtime_started(self) -> None:
|
||||||
|
state = self.runtime_manager.ensure_started(int(self.model_config_state.get("version") or 0))
|
||||||
|
if state:
|
||||||
|
print(
|
||||||
|
"[agent] runtime started "
|
||||||
|
f"generation={state.get('generation')} mode={state.get('mode')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def submit_child_task(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
response = request_json(
|
||||||
|
"POST",
|
||||||
|
self._url("/api/tasks"),
|
||||||
|
payload,
|
||||||
|
timeout=self.config.request_timeout_seconds,
|
||||||
|
)
|
||||||
|
return response.get("task") or {}
|
||||||
|
|
||||||
|
def task_model_config_state(self) -> dict[str, Any]:
|
||||||
|
state = dict(self.model_config_state)
|
||||||
|
value = dict(state.get("value") or {})
|
||||||
|
if self.state.get("agent_id") and self.state.get("token"):
|
||||||
|
value["controller_model_proxy_url"] = self._url(
|
||||||
|
f"/api/agents/{int(self.state['agent_id'])}/model/chat-json"
|
||||||
|
)
|
||||||
|
value["controller_model_proxy_token"] = str(self.state["token"])
|
||||||
|
value["controller_model_proxy_enabled"] = True
|
||||||
|
state["value"] = value
|
||||||
|
return state
|
||||||
|
|
||||||
|
def execute_task(self, task: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
session = AgentSession(
|
||||||
|
task=task,
|
||||||
|
model_config_state=self.task_model_config_state(),
|
||||||
|
memory_path=self.config.memory_path,
|
||||||
|
request_timeout_seconds=self.config.request_timeout_seconds,
|
||||||
|
submit_task=self.submit_child_task,
|
||||||
|
)
|
||||||
|
return session.run()
|
||||||
|
|
||||||
|
def task_execution_error_result(self, task: dict[str, Any], exc: Exception, started: float) -> dict[str, Any]:
|
||||||
|
payload = task.get("payload") if isinstance(task.get("payload"), dict) else {}
|
||||||
|
model_state = self.task_model_config_state()
|
||||||
|
model_value = model_state.get("value") if isinstance(model_state.get("value"), dict) else {}
|
||||||
|
trace_lines = traceback.format_exc().splitlines()
|
||||||
|
message = f"Controlled agent task execution failed: {exc}"
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": message,
|
||||||
|
"task_type": payload.get("task_type") or "manual",
|
||||||
|
"agent_framework": "ufo-inspired-lite",
|
||||||
|
"p0_mode": True,
|
||||||
|
"needs_user_input": True,
|
||||||
|
"target_url": payload.get("target_url") or payload.get("url") or "",
|
||||||
|
"duration_seconds": round(time.monotonic() - started, 3),
|
||||||
|
"action_duration_seconds": 0,
|
||||||
|
"model_duration_seconds": 0,
|
||||||
|
"model_available": bool(
|
||||||
|
model_value.get("controller_model_proxy_url")
|
||||||
|
or (model_value.get("vllm_base_url") and model_value.get("vllm_model"))
|
||||||
|
),
|
||||||
|
"model_errors": [message],
|
||||||
|
"exception_type": type(exc).__name__,
|
||||||
|
"logs": [
|
||||||
|
"Task execution raised an exception before completion.",
|
||||||
|
*trace_lines[-25:],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def report_task_result(self, task_id: int, result: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
agent_id = int(self.state["agent_id"])
|
||||||
|
return request_json(
|
||||||
|
"POST",
|
||||||
|
self._url(f"/api/agents/{agent_id}/tasks/{task_id}/result"),
|
||||||
|
result,
|
||||||
|
token=str(self.state["token"]),
|
||||||
|
timeout=self.config.request_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def claim_task(self) -> dict[str, Any] | None:
|
||||||
|
agent_id = int(self.state["agent_id"])
|
||||||
|
response = request_json(
|
||||||
|
"POST",
|
||||||
|
self._url(f"/api/agents/{agent_id}/tasks/claim"),
|
||||||
|
{},
|
||||||
|
token=str(self.state["token"]),
|
||||||
|
timeout=self.config.request_timeout_seconds,
|
||||||
|
)
|
||||||
|
task = response.get("task")
|
||||||
|
if task:
|
||||||
|
self.last_task_claim_at = utc_now()
|
||||||
|
print(f"Claimed task: id={task.get('id')} external_id={task.get('external_id')}")
|
||||||
|
self.current_status = "running"
|
||||||
|
task_started = time.monotonic()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
self.send_heartbeat()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[agent] running heartbeat failed: {exc}")
|
||||||
|
try:
|
||||||
|
result = self.execute_task(task)
|
||||||
|
except Exception as exc:
|
||||||
|
result = self.task_execution_error_result(task, exc, task_started)
|
||||||
|
print(result["message"], file=sys.stderr)
|
||||||
|
self.report_task_result(int(task["id"]), result)
|
||||||
|
print(
|
||||||
|
"Reported task result: "
|
||||||
|
f"id={task.get('id')} success={result.get('success')} message={result.get('message')}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.current_status = "idle"
|
||||||
|
else:
|
||||||
|
print("No ready task.")
|
||||||
|
return task
|
||||||
|
|
||||||
|
def local_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"service": "controlled-agent",
|
||||||
|
"version": __version__,
|
||||||
|
"configured_controller_url": self.config.controller_url,
|
||||||
|
"controller_url": self.controller_url,
|
||||||
|
"controller_connected": bool(self.controller_url),
|
||||||
|
"last_connection_error": self.last_connection_error,
|
||||||
|
"public_id": self.public_id,
|
||||||
|
"agent_id": self.state.get("agent_id"),
|
||||||
|
"deployment_type": self.deployment["deployment_type"],
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"reported_host": self.host,
|
||||||
|
"reported_ip": self.ip,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"last_heartbeat_at": self.last_heartbeat_at,
|
||||||
|
"last_task_claim_at": self.last_task_claim_at,
|
||||||
|
"model_config": public_model_config_summary(self.model_config_state),
|
||||||
|
"runtime": self.runtime_manager.state,
|
||||||
|
"agent_core": {
|
||||||
|
"framework": "ufo-inspired-lite",
|
||||||
|
"memory_path": str(self.config.memory_path),
|
||||||
|
"memory": self.agent_memory_status(),
|
||||||
|
"tools": self.tool_registry.descriptions(),
|
||||||
|
},
|
||||||
|
"local_service": {
|
||||||
|
"enabled": self.config.local_service_enabled,
|
||||||
|
"host": self.config.local_service_host,
|
||||||
|
"port": self.config.local_service_port,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def agent_memory_status(self) -> dict[str, Any]:
|
||||||
|
if not self.config.memory_path.exists():
|
||||||
|
return {"exists": False, "counts": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(self.config.memory_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
return {"exists": True, "error": str(exc), "counts": {}}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"exists": True, "error": "Memory file is not an object.", "counts": {}}
|
||||||
|
return {
|
||||||
|
"exists": True,
|
||||||
|
"counts": {
|
||||||
|
key: len(value) if isinstance(value, list) else 0
|
||||||
|
for key, value in data.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def start_local_service(self) -> None:
|
||||||
|
if not self.config.local_service_enabled:
|
||||||
|
return
|
||||||
|
self.local_service = LocalService(
|
||||||
|
self.config.local_service_host,
|
||||||
|
self.config.local_service_port,
|
||||||
|
self.local_status,
|
||||||
|
)
|
||||||
|
self.local_service.start()
|
||||||
|
print(
|
||||||
|
"Controlled agent local service started at "
|
||||||
|
f"http://127.0.0.1:{self.config.local_service_port}/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, once: bool = False) -> None:
|
||||||
|
self.start_local_service()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if not self.controller_url:
|
||||||
|
self.resolve_controller()
|
||||||
|
self.ensure_registered()
|
||||||
|
if not self.runtime_started:
|
||||||
|
self.ensure_runtime_started()
|
||||||
|
self.runtime_started = True
|
||||||
|
self.send_heartbeat()
|
||||||
|
self.claim_task()
|
||||||
|
if once:
|
||||||
|
return
|
||||||
|
time.sleep(min(self.config.heartbeat_interval_seconds, self.config.task_poll_interval_seconds))
|
||||||
|
except (RuntimeError, HttpClientError, OSError) as exc:
|
||||||
|
if once:
|
||||||
|
raise
|
||||||
|
self.handle_controller_error(exc)
|
||||||
|
time.sleep(max(1.0, self.config.controller_retry_interval_seconds))
|
||||||
|
finally:
|
||||||
|
self.runtime_manager.stop()
|
||||||
|
if once and self.local_service:
|
||||||
|
self.local_service.stop()
|
||||||
|
|
||||||
|
def handle_controller_error(self, exc: Exception) -> None:
|
||||||
|
message = str(exc)
|
||||||
|
self.last_connection_error = message
|
||||||
|
self.controller_retry_count += 1
|
||||||
|
|
||||||
|
if isinstance(exc, HttpClientError) and exc.status in {401, 404}:
|
||||||
|
print(
|
||||||
|
"[agent] saved controller credentials are no longer valid; "
|
||||||
|
"clearing local registration and retrying."
|
||||||
|
)
|
||||||
|
self.state = {}
|
||||||
|
self.registration_confirmed = False
|
||||||
|
save_state(self.config.state_path, self.state)
|
||||||
|
|
||||||
|
if not isinstance(exc, HttpClientError) or exc.status in {0, 401, 404, 409} or exc.status >= 500:
|
||||||
|
self.controller_url = None
|
||||||
|
|
||||||
|
if self.controller_retry_count == 1 or self.controller_retry_count % 10 == 0:
|
||||||
|
print(
|
||||||
|
"[agent] controller unavailable; agent stays running and will keep scanning. "
|
||||||
|
f"retry={self.controller_retry_count} error={message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="System test controlled agent")
|
||||||
|
parser.add_argument("--once", action="store_true", help="register, heartbeat, claim once, then exit")
|
||||||
|
parser.add_argument("--controller-url", help="override CONTROLLER_URL")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
args = parse_args(argv or sys.argv[1:])
|
||||||
|
config = AgentConfig.from_env()
|
||||||
|
if args.controller_url:
|
||||||
|
config = AgentConfig(
|
||||||
|
root_dir=config.root_dir,
|
||||||
|
controller_url=args.controller_url.rstrip("/"),
|
||||||
|
controller_discovery_enabled=config.controller_discovery_enabled,
|
||||||
|
controller_discovery_ports=config.controller_discovery_ports,
|
||||||
|
controller_discovery_timeout_seconds=config.controller_discovery_timeout_seconds,
|
||||||
|
state_path=config.state_path,
|
||||||
|
model_config_path=config.model_config_path,
|
||||||
|
runtime_state_path=config.runtime_state_path,
|
||||||
|
memory_path=config.memory_path,
|
||||||
|
display_name=config.display_name,
|
||||||
|
deployment_type=config.deployment_type,
|
||||||
|
heartbeat_interval_seconds=config.heartbeat_interval_seconds,
|
||||||
|
task_poll_interval_seconds=config.task_poll_interval_seconds,
|
||||||
|
controller_retry_interval_seconds=config.controller_retry_interval_seconds,
|
||||||
|
request_timeout_seconds=config.request_timeout_seconds,
|
||||||
|
local_service_enabled=config.local_service_enabled,
|
||||||
|
local_service_host=config.local_service_host,
|
||||||
|
local_service_port=config.local_service_port,
|
||||||
|
runtime_command=config.runtime_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = ControlledAgent(config)
|
||||||
|
agent.run(once=args.once)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
raise SystemExit(2) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
print(
|
||||||
|
"Local controlled-agent service failed to start. "
|
||||||
|
f"Check whether port {config.local_service_port} is already in use. "
|
||||||
|
f"Original error: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise SystemExit(2) from exc
|
||||||
|
except HttpClientError as exc:
|
||||||
|
if exc.status == 409:
|
||||||
|
print(
|
||||||
|
"Registration conflict: this machine is already registered on the controller, "
|
||||||
|
"but the local token was not found. Restore data/agent_state.json or remove the "
|
||||||
|
"existing agent record from the controller database.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
raise SystemExit(2) from exc
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nControlled agent stopping...")
|
||||||
|
except Exception as exc:
|
||||||
|
print("Unexpected SystemTest-Agent error:", file=sys.stderr)
|
||||||
|
traceback.print_exc()
|
||||||
|
raise SystemExit(2) from exc
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path: Path, state: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal EnableExtensions
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if not "%~1"=="" set "CONTROLLER_URL=%~1"
|
||||||
|
set "EXIT_CODE=0"
|
||||||
|
|
||||||
|
if not exist "logs" mkdir "logs" >nul 2>nul
|
||||||
|
set "LOG_FILE=%~dp0logs\agent_start_latest.log"
|
||||||
|
if not defined PYTHONIOENCODING set "PYTHONIOENCODING=utf-8"
|
||||||
|
call :set_stamp
|
||||||
|
> "%LOG_FILE%" echo [%STAMP%] SystemTest-Agent startup
|
||||||
|
>> "%LOG_FILE%" echo Working directory: %CD%
|
||||||
|
if defined CONTROLLER_URL >> "%LOG_FILE%" echo CONTROLLER_URL=%CONTROLLER_URL%
|
||||||
|
echo Startup log: "%LOG_FILE%"
|
||||||
|
|
||||||
|
if defined PYTHON_EXE (
|
||||||
|
if exist "%PYTHON_EXE%" (
|
||||||
|
call :run_python "%PYTHON_EXE%" -u -m app
|
||||||
|
goto :after_run
|
||||||
|
) else (
|
||||||
|
>> "%LOG_FILE%" echo PYTHON_EXE is set but was not found: %PYTHON_EXE%
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
set "CODEX_PYTHON=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||||
|
if exist "%CODEX_PYTHON%" (
|
||||||
|
call :run_python "%CODEX_PYTHON%" -u -m app
|
||||||
|
goto :after_run
|
||||||
|
)
|
||||||
|
|
||||||
|
call :run_python python -u -m app
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
call :run_python py -3 -u -m app
|
||||||
|
)
|
||||||
|
|
||||||
|
:after_run
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo SystemTest-Agent exited with code %EXIT_CODE%.
|
||||||
|
echo Full log: "%LOG_FILE%"
|
||||||
|
echo.
|
||||||
|
echo Last log lines:
|
||||||
|
call :show_log_tail
|
||||||
|
echo.
|
||||||
|
echo Common causes:
|
||||||
|
echo 1. Controller is not reachable from this device.
|
||||||
|
echo Try: start_agent.bat http://^<controller-lan-ip^>:8001
|
||||||
|
echo 2. Python 3 is not installed or not in PATH.
|
||||||
|
echo 3. Local service port 18081 is already in use.
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
if not "%AGENT_NO_PAUSE%"=="1" (
|
||||||
|
echo.
|
||||||
|
echo SystemTest-Agent stopped. Full log: "%LOG_FILE%"
|
||||||
|
echo Press any key to close this window.
|
||||||
|
pause >nul
|
||||||
|
)
|
||||||
|
|
||||||
|
:end
|
||||||
|
endlocal & exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:run_python
|
||||||
|
echo. >> "%LOG_FILE%"
|
||||||
|
call :set_stamp
|
||||||
|
echo [%STAMP%] Running: %* >> "%LOG_FILE%"
|
||||||
|
%* >> "%LOG_FILE%" 2>&1
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
call :set_stamp
|
||||||
|
echo [%STAMP%] Exit code: %EXIT_CODE% >> "%LOG_FILE%"
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:set_stamp
|
||||||
|
for /f "delims=" %%I in ('powershell -NoProfile -Command "Get-Date -Format yyyy-MM-ddTHH:mm:ss"') do set "STAMP=%%I"
|
||||||
|
if not defined STAMP set "STAMP=%TIME%"
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:show_log_tail
|
||||||
|
powershell -NoProfile -Command "if (Test-Path -LiteralPath '%LOG_FILE%') { Get-Content -LiteralPath '%LOG_FILE%' -Tail 80 -Encoding UTF8 }"
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
param(
|
||||||
|
[string]$ControllerUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location -LiteralPath $PSScriptRoot
|
||||||
|
|
||||||
|
if ($ControllerUrl) {
|
||||||
|
$env:CONTROLLER_URL = $ControllerUrl
|
||||||
|
}
|
||||||
|
if (-not $env:PYTHONIOENCODING) {
|
||||||
|
$env:PYTHONIOENCODING = "utf-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
$logDir = Join-Path $PSScriptRoot "logs"
|
||||||
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||||
|
$logFile = Join-Path $logDir "agent_start_latest.log"
|
||||||
|
@(
|
||||||
|
"[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] SystemTest-Agent startup"
|
||||||
|
"Working directory: $PWD"
|
||||||
|
"CONTROLLER_URL=$env:CONTROLLER_URL"
|
||||||
|
) | Set-Content -LiteralPath $logFile -Encoding UTF8
|
||||||
|
Write-Host "Startup log: $logFile"
|
||||||
|
|
||||||
|
function Write-ExitHints {
|
||||||
|
param([int]$ExitCode)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SystemTest-Agent exited with code $ExitCode."
|
||||||
|
Write-Host "Full log: $logFile"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Last log lines:"
|
||||||
|
if (Test-Path -LiteralPath $logFile) {
|
||||||
|
Get-Content -LiteralPath $logFile -Tail 80
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Common causes:"
|
||||||
|
Write-Host " 1. Controller is not reachable from this device."
|
||||||
|
Write-Host " Try: .\start_agent.ps1 -ControllerUrl http://<controller-lan-ip>:8001"
|
||||||
|
Write-Host " 2. Python 3 is not installed or not in PATH."
|
||||||
|
Write-Host " 3. Local service port 18081 is already in use."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pause-IfNeeded {
|
||||||
|
if ($env:AGENT_NO_PAUSE -ne "1") {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "SystemTest-Agent stopped. Full log: $logFile"
|
||||||
|
Read-Host "Press Enter to close this window" | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-CommandArgumentString {
|
||||||
|
param([string[]]$Arguments)
|
||||||
|
$quoted = foreach ($argument in $Arguments) {
|
||||||
|
if ($argument -match '[\s"]') {
|
||||||
|
'"' + ($argument -replace '"', '\"') + '"'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$argument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ($quoted -join ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Quote-CmdArgument {
|
||||||
|
param([string]$Value)
|
||||||
|
return '"' + ($Value -replace '"', '\"') + '"'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-LoggedProcess {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[string]$LogFilePath
|
||||||
|
)
|
||||||
|
|
||||||
|
$commandLine = "$(Quote-CmdArgument -Value $FilePath) $(ConvertTo-CommandArgumentString -Arguments $Arguments) >> $(Quote-CmdArgument -Value $LogFilePath) 2>&1"
|
||||||
|
& $env:ComSpec /d /s /c $commandLine
|
||||||
|
return $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonExe = $null
|
||||||
|
$pythonArgs = @()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($env:PYTHON_EXE -and (Test-Path -LiteralPath $env:PYTHON_EXE)) {
|
||||||
|
$pythonExe = $env:PYTHON_EXE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
$bundledPython = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'
|
||||||
|
if (Test-Path -LiteralPath $bundledPython) {
|
||||||
|
$pythonExe = $bundledPython
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCommand) {
|
||||||
|
$pythonExe = $pythonCommand.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
$pythonCommand = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCommand) {
|
||||||
|
$pythonExe = $pythonCommand.Source
|
||||||
|
$pythonArgs = @('-3')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
throw "Python 3 was not found. Install Python 3, set PYTHON_EXE, or add Python to PATH."
|
||||||
|
}
|
||||||
|
|
||||||
|
$runArgs = @($pythonArgs) + @("-u", "-m", "app")
|
||||||
|
$runLine = "Running: $pythonExe $(ConvertTo-CommandArgumentString -Arguments $runArgs)"
|
||||||
|
Write-Host $runLine
|
||||||
|
Add-Content -LiteralPath $logFile -Encoding UTF8 -Value $runLine
|
||||||
|
$exitCode = Invoke-LoggedProcess -FilePath $pythonExe -Arguments $runArgs -LogFilePath $logFile
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$exitCode = 2
|
||||||
|
Write-Host ($_ | Out-String)
|
||||||
|
Add-Content -LiteralPath $logFile -Encoding UTF8 -Value ($_ | Out-String)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
Write-ExitHints -ExitCode $exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
Pause-IfNeeded
|
||||||
|
exit $exitCode
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# SystemTest-Controller
|
||||||
|
|
||||||
|
P0-01 主控端最小可用实现,使用 Python 标准库提供 HTTP API、SQLite 状态库和静态面板。
|
||||||
|
|
||||||
|
## 一键启动
|
||||||
|
|
||||||
|
PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start_controller.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
CMD:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
start_controller.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
默认地址与 `Agent-S-Controller/start_agent_controller.bat` 保持一致:
|
||||||
|
|
||||||
|
- Health: `http://127.0.0.1:8001/health`
|
||||||
|
- Panel: `http://127.0.0.1:8001/`
|
||||||
|
|
||||||
|
启动成功后会自动打开 Web 面板;如需关闭自动弹出,设置 `CONTROLLER_NO_BROWSER=1`。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
复制 `.env.example` 为 `.env` 后可调整:
|
||||||
|
|
||||||
|
- `CONTROLLER_HOST`: 默认 `0.0.0.0`
|
||||||
|
- `CONTROLLER_PORT`: 默认 `8001`
|
||||||
|
- `CONTROLLER_PUBLIC_BASE_URL`: 被控端访问主控的 URL
|
||||||
|
- `CONTROLLER_DEPLOYMENT_MODE`: 主控部署形态标识,默认 `script`
|
||||||
|
- `CONTROLLER_DATABASE`: SQLite 数据库路径
|
||||||
|
- `AGENT_STALE_AFTER_SECONDS`: 被控端超过该秒数无心跳后显示为 `offline`,默认 `5`
|
||||||
|
- `CONTROLLER_NO_BROWSER`: 设为 `1` 时不自动打开浏览器
|
||||||
|
|
||||||
|
## 已完成的 P0-01 验收点
|
||||||
|
|
||||||
|
- 启动时读取 `.env`
|
||||||
|
- 启动前检查数据库、端口、静态面板文件
|
||||||
|
- `/health` 返回健康状态和部署形态
|
||||||
|
- `/` 可访问静态面板
|
||||||
|
- 提供被控端注册、心跳、领任务 API
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""System test controller package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .server import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
if not path.exists():
|
||||||
|
return values
|
||||||
|
|
||||||
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key:
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _get_env(name: str, default: str, file_values: dict[str, str]) -> str:
|
||||||
|
return os.environ.get(name, file_values.get(name, default))
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(value: str) -> Path:
|
||||||
|
path = Path(value)
|
||||||
|
if path.is_absolute():
|
||||||
|
return path
|
||||||
|
return ROOT_DIR / path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ControllerConfig:
|
||||||
|
root_dir: Path
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
database_path: Path
|
||||||
|
static_dir: Path
|
||||||
|
controller_name: str
|
||||||
|
deployment_mode: str
|
||||||
|
public_base_url: str
|
||||||
|
agent_stale_after_seconds: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "ControllerConfig":
|
||||||
|
file_values = _load_env_file(ROOT_DIR / ".env")
|
||||||
|
port = _as_int(_get_env("CONTROLLER_PORT", "8001", file_values), 8001)
|
||||||
|
host = _get_env("CONTROLLER_HOST", "0.0.0.0", file_values)
|
||||||
|
public_base_url = _get_env(
|
||||||
|
"CONTROLLER_PUBLIC_BASE_URL",
|
||||||
|
f"http://127.0.0.1:{port}",
|
||||||
|
file_values,
|
||||||
|
).rstrip("/")
|
||||||
|
stale_after = _as_int(
|
||||||
|
_get_env("AGENT_STALE_AFTER_SECONDS", "5", file_values),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
root_dir=ROOT_DIR,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
database_path=_resolve_path(
|
||||||
|
_get_env("CONTROLLER_DATABASE", "data/controller.db", file_values)
|
||||||
|
),
|
||||||
|
static_dir=_resolve_path(_get_env("CONTROLLER_STATIC_DIR", "web", file_values)),
|
||||||
|
controller_name=_get_env(
|
||||||
|
"CONTROLLER_NAME",
|
||||||
|
"System Test Controller",
|
||||||
|
file_values,
|
||||||
|
),
|
||||||
|
deployment_mode=_get_env("CONTROLLER_DEPLOYMENT_MODE", "script", file_values),
|
||||||
|
public_base_url=public_base_url,
|
||||||
|
agent_stale_after_seconds=stale_after,
|
||||||
|
)
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if defined PYTHON_EXE (
|
||||||
|
if exist "%PYTHON_EXE%" (
|
||||||
|
"%PYTHON_EXE%" -m app
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
set "CODEX_PYTHON=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||||
|
if exist "%CODEX_PYTHON%" (
|
||||||
|
"%CODEX_PYTHON%" -m app
|
||||||
|
goto :end
|
||||||
|
)
|
||||||
|
|
||||||
|
python -m app
|
||||||
|
if errorlevel 1 py -3 -m app
|
||||||
|
|
||||||
|
:end
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
param(
|
||||||
|
[string]$HostName,
|
||||||
|
[int]$Port = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
Set-Location -LiteralPath $PSScriptRoot
|
||||||
|
|
||||||
|
if ($HostName) {
|
||||||
|
$env:CONTROLLER_HOST = $HostName
|
||||||
|
}
|
||||||
|
if ($Port -gt 0) {
|
||||||
|
$env:CONTROLLER_PORT = [string]$Port
|
||||||
|
if (-not $env:CONTROLLER_PUBLIC_BASE_URL) {
|
||||||
|
$env:CONTROLLER_PUBLIC_BASE_URL = "http://127.0.0.1:$Port"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonExe = $null
|
||||||
|
$pythonArgs = @()
|
||||||
|
|
||||||
|
if ($env:PYTHON_EXE -and (Test-Path -LiteralPath $env:PYTHON_EXE)) {
|
||||||
|
$pythonExe = $env:PYTHON_EXE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
$bundledPython = Join-Path $env:USERPROFILE '.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe'
|
||||||
|
if (Test-Path -LiteralPath $bundledPython) {
|
||||||
|
$pythonExe = $bundledPython
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
$pythonCommand = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCommand) {
|
||||||
|
$pythonExe = $pythonCommand.Source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
$pythonCommand = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCommand) {
|
||||||
|
$pythonExe = $pythonCommand.Source
|
||||||
|
$pythonArgs = @('-3')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $pythonExe) {
|
||||||
|
throw "Python 3 was not found. Install Python 3, set PYTHON_EXE, or add Python to PATH."
|
||||||
|
}
|
||||||
|
|
||||||
|
& $pythonExe @pythonArgs -m app
|
||||||
|
exit $LASTEXITCODE
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>系统测试 Agent 主控台</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<svg class="icon-sprite" aria-hidden="true">
|
||||||
|
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 8-6-16-3 8H2" /></symbol>
|
||||||
|
<symbol id="i-bar" viewBox="0 0 24 24"><path d="M3 3v18h18" /><path d="M7 16V9" /><path d="M12 16V5" /><path d="M17 16v-3" /></symbol>
|
||||||
|
<symbol id="i-check" viewBox="0 0 24 24"><path d="m20 6-11 11-5-5" /></symbol>
|
||||||
|
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></symbol>
|
||||||
|
<symbol id="i-file" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></symbol>
|
||||||
|
<symbol id="i-monitor" viewBox="0 0 24 24"><path d="M3 5h18v12H3z" /><path d="M8 21h8" /><path d="M12 17v4" /></symbol>
|
||||||
|
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 5 11 7-11 7z" /></symbol>
|
||||||
|
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 1-15.5 6.2" /><path d="M3 12A9 9 0 0 1 18.5 5.8" /><path d="M3 18v-5h5" /><path d="M21 6v5h-5" /></symbol>
|
||||||
|
<symbol id="i-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" /><path d="M17 21v-8H7v8" /><path d="M7 3v5h8" /></symbol>
|
||||||
|
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5z" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 0 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1A2 2 0 0 1 4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.6-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9l-.1-.1A2 2 0 0 1 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.6V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1A2 2 0 0 1 19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.1a2 2 0 0 1 0 4H21a1.7 1.7 0 0 0-1.6 1z" /></symbol>
|
||||||
|
<symbol id="i-square" viewBox="0 0 24 24"><path d="M6 6h12v12H6z" /></symbol>
|
||||||
|
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 21V9" /><path d="m7 14 5-5 5 5" /><path d="M5 3h14" /></symbol>
|
||||||
|
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.8" /><path d="M16 3.1a4 4 0 0 1 0 7.8" /></symbol>
|
||||||
|
<symbol id="i-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a5 5 0 0 0-6.4 6.4L3 18l3 3 5.3-5.3a5 5 0 0 0 6.4-6.4L14 13l-3-3z" /></symbol>
|
||||||
|
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></symbol>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark">ST</span>
|
||||||
|
<div>
|
||||||
|
<strong>系统测试 Agent</strong>
|
||||||
|
<span>Controller</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav" aria-label="主控导航">
|
||||||
|
<button class="nav-item is-active" type="button" data-section="overview"><svg><use href="#i-activity"></use></svg><span>总览</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="plans"><svg><use href="#i-file"></use></svg><span>系统测试</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="console"><svg><use href="#i-play"></use></svg><span>运行控制台</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="devices"><svg><use href="#i-monitor"></use></svg><span>设备监控</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="models"><svg><use href="#i-settings"></use></svg><span>模型配置</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="tools"><svg><use href="#i-wrench"></use></svg><span>Tool 配置</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="metrics"><svg><use href="#i-bar"></use></svg><span>指标看板</span></button>
|
||||||
|
<button class="nav-item" type="button" data-section="reports"><svg><use href="#i-download"></use></svg><span>报告中心</span></button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main-shell">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1 id="sectionTitle">总览</h1>
|
||||||
|
<p id="serviceMeta">Loading...</p>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span class="status-pill" id="healthPill">checking</span>
|
||||||
|
<button class="icon-button" id="refreshButton" type="button" title="刷新">
|
||||||
|
<svg><use href="#i-refresh"></use></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<section class="view is-active" id="view-overview"></section>
|
||||||
|
<section class="view" id="view-plans"></section>
|
||||||
|
<section class="view" id="view-console"></section>
|
||||||
|
<section class="view" id="view-devices"></section>
|
||||||
|
<section class="view" id="view-models"></section>
|
||||||
|
<section class="view" id="view-tools"></section>
|
||||||
|
<section class="view" id="view-metrics"></section>
|
||||||
|
<section class="view" id="view-reports"></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,997 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
background: #f4f6f8;
|
||||||
|
color: #172033;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-alt: #f8fafc;
|
||||||
|
--line: #d9e2ec;
|
||||||
|
--line-soft: #e8eef5;
|
||||||
|
--text: #172033;
|
||||||
|
--muted: #607086;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-dark: #1d4ed8;
|
||||||
|
--green: #12805c;
|
||||||
|
--green-bg: #e9f7f1;
|
||||||
|
--amber: #a15c00;
|
||||||
|
--amber-bg: #fff4df;
|
||||||
|
--red: #b42318;
|
||||||
|
--red-bg: #fff1f0;
|
||||||
|
--violet: #6d4aff;
|
||||||
|
--shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-sprite {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 232px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
padding: 18px 14px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #4b5563;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1f2937;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong,
|
||||||
|
.brand span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: #aab4c3;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
height: 38px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.is-active {
|
||||||
|
background: #e5edff;
|
||||||
|
color: #17356f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-shell {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
min-height: 72px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#serviceMeta {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 18px 20px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.is-active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid > *,
|
||||||
|
.kpi,
|
||||||
|
.surface {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col {
|
||||||
|
grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.3fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-col {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-header {
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-body {
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.icon-button {
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid #b8c5d6;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #25344a;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.primary:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger {
|
||||||
|
border-color: #f1b6b0;
|
||||||
|
color: var(--red);
|
||||||
|
background: #fffafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled,
|
||||||
|
.icon-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 34px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill,
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border: 1px solid #c8d2df;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #42526a;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.ok,
|
||||||
|
.badge.ok {
|
||||||
|
border-color: #9bd7bd;
|
||||||
|
background: var(--green-bg);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill.fail,
|
||||||
|
.badge.fail {
|
||||||
|
border-color: #f1b6b0;
|
||||||
|
background: var(--red-bg);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warn {
|
||||||
|
border-color: #f2cf92;
|
||||||
|
background: var(--amber-bg);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.info {
|
||||||
|
border-color: #bfd2ff;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #174a9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
min-height: 94px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi label {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid.full,
|
||||||
|
.field.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
color: #25344a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #bfccdc;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 0 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 170px;
|
||||||
|
padding: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 820px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-table table {
|
||||||
|
min-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--line-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: #5b6a7f;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.actions {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 26px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cell {
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
|
gap: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dt {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e6edf5;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px minmax(0, 1fr) 72px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
max-width: min(420px, calc(100vw - 36px));
|
||||||
|
background: #111827;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-select {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.small {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-panel {
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-panel .field {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-empty {
|
||||||
|
min-height: 36px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px dashed #c9d7ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-empty.fail {
|
||||||
|
border-color: #f2bbb5;
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-summary {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-result-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-result-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-result-main {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-result-main strong,
|
||||||
|
.site-url,
|
||||||
|
.site-meta {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-url {
|
||||||
|
color: #174a9c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(220px, 0.7fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-empty {
|
||||||
|
min-height: 42px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px dashed #c9d7ea;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-empty.fail {
|
||||||
|
border-color: #f2bbb5;
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-tree {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px;
|
||||||
|
padding-left: calc(10px + (var(--depth, 0) * 22px));
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-row input {
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-main {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-main strong,
|
||||||
|
.discovery-main span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-main > span:not(.site-url) {
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discovery-note {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-test-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-title {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #1f2d42;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 7px 8px;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-list.logs li {
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.ok {
|
||||||
|
border-color: #a9dec7;
|
||||||
|
background: var(--green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.fail {
|
||||||
|
border-color: #f2bbb5;
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.neutral {
|
||||||
|
border-color: #c9d7ea;
|
||||||
|
background: #f5f8fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result-icon {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.ok .test-result-icon {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result.fail .test-result-icon {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-result p {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #40516a;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-kv {
|
||||||
|
margin: 12px 0;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subheading {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-chip-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #bdd0ea;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #243650;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-list {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #40516a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-preview {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #d7e2f2;
|
||||||
|
font-family: Consolas, "Cascadia Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1060px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col,
|
||||||
|
.three-col,
|
||||||
|
.kpi-grid,
|
||||||
|
.field-grid,
|
||||||
|
.discovery-fields,
|
||||||
|
.site-scan-fields,
|
||||||
|
.site-result-row,
|
||||||
|
.task-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-scan-action .button,
|
||||||
|
.site-result-row .button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.topbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-header,
|
||||||
|
.toolbar {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user