Add SystemTest Agent and Controller

This commit is contained in:
Codex committed 2026-06-22 10:37:20 +08:00
commit 53f523f187
31 files changed
+9150

No files matched your search

+52
View File
@@ -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/
+69
View File
@@ -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>`
- 注册与心跳上报物理机/虚拟机部署形态
+3
View File
@@ -0,0 +1,3 @@
"""System test controlled agent package."""
__version__ = "0.1.0"
+5
View File
@@ -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"]
+124
View File
@@ -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 ""),
)
+103
View File
@@ -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()
+848
View File
@@ -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
+790
View File
@@ -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.",
},
]
+162
View File
@@ -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"
)
+60
View File
@@ -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
+104
View File
@@ -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(),
},
}
+130
View File
@@ -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)
+128
View File
@@ -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
+509
View File
@@ -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
+20
View File
@@ -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")
+80
View File
@@ -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
+132
View File
@@ -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
+44
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""System test controller package."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
from .server import main
if __name__ == "__main__":
main()
+89
View File
@@ -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
+79
View File
@@ -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>
+997
View File
@@ -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;
}
}