Compare commits

...
4 Commits
49 changed files with 2220 additions and 93 deletions

No files matched your search

+4
View File
@@ -9,6 +9,10 @@ OPENAI_TIMEOUT_SECONDS=300
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。 # 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。 # 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
OPENAI_USE_SYSTEM_PROXY=false OPENAI_USE_SYSTEM_PROXY=false
# 默认 websocket,要求网关支持 Responses WebSocket;不支持时可在网页切回 http。
OPENAI_TRANSPORT=websocket
# 内部迁移标记。升级时一次性将旧 HTTP 配置切换为 WebSocket;保留此标记以记住后续手动选择。
ARTEMIS_TRANSPORT_DEFAULT_VERSION=1
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。 # 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120 ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
+3 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。
**2.6.0**:新增 Android 原生控件读写、条件等待、容器滚动、双指缩放与连续多指手势;支持状态确保、稳定数值反馈与无效动作保护,修复多窗口原生查询和选择器像素边界,并补齐前端中文动作名称。见 [2.6.0 发行说明](docs/releases/2.6.0.md)。包含 [2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。 **2.7.1**:统一任务操作与录屏使用的 ADB,修复模型空结果引起的应用查找误报,并在任务资料 ZIP 中保留运行环境诊断和批次原暂停原因。见 [2.7.1 发行说明](docs/releases/2.7.1.md)。继续包含 [2.7.0 的 Responses WebSocket 增量传输](docs/releases/2.7.0.md)、[2.6.0 的 Android 原生控件与多指手势](docs/releases/2.6.0.md)、[2.5.0 的动作序列与缓存统计](docs/releases/2.5.0.md)及 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md);SDK 与插件继续独立版本化,升级主程序不会自动更新或授权插件。
## 主要功能 ## 主要功能
@@ -61,6 +61,8 @@ cd Artemis-Eapil
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
模型传输默认 **WebSocket(增量传输)**。升级后首次启动会将旧的全局 HTTP 配置一并迁移为 WebSocket;之后仍可在“模型配置 → 传输协议”手动切回 HTTP,后续启动保留手动选择。Flash、Pro、探索及辅助模型的异步请求复用 Responses 连接,历史一致时只上传新增输入;网关需支持 `/v1/responses` 的 WebSocket 升级与响应链。详细行为与流量诊断见[传输说明](docs/ai-automation.md#responses-websocket-增量传输)。
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。 “功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。 首页“功能配置”提供“Flash 最终 AI 判定与分析”,默认关闭。切换后自动保存并提示结果,失败恢复原值;无需重启服务。开启后,Flash 在执行结束时根据用例需求和执行步骤进行独立复核;关闭后显示“执行完成 · 未做 AI 判定”,保留执行步骤、截图和报告,不自动记为测试通过。此设置适用于所有 Flash 任务,包括批量、精选、MCP/SDK 和探索提炼用例的 Flash 复跑;保存后对尚未开始的任务生效,运行中的任务保持原设置。Pro 和主动探索不受影响。其余功能配置仍需点击底部保存按钮。
@@ -123,9 +123,18 @@ def recover_batches():
if batch["status"] in {"running", "pausing", "paused"}: if batch["status"] in {"running", "pausing", "paused"}:
if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])): if any(str(job["session_id"]) in live_sessions for job in store.jobs(batch["id"])):
continue continue
recovery_reason = "服务已重启;请检查中断任务,点击继续执行未开始任务"
batch.setdefault("recovery_events", []).append(
{
"message": recovery_reason,
"recovered_at": time.time(),
"previous_reason": batch.get("reason"),
"previous_pause_source": batch.get("pause_source"),
}
)
batch.update( batch.update(
status="paused", status="paused",
reason="服务已重启;请检查中断任务,点击继续执行未开始任务", reason=batch.get("reason") or recovery_reason,
pause_source="recovery", pause_source="recovery",
) )
for job in store.jobs(batch["id"]): for job in store.jobs(batch["id"]):
@@ -330,7 +339,7 @@ async def tick_batches():
else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次" else "无法确认所选设备在线且已授权,请恢复设备连接后继续批次"
) )
if rejection: if rejection:
batch.update(status="paused", reason=str(rejection)) batch.update(status="paused", reason=str(rejection), pause_source="device")
store.put("batch", batch["id"], batch) store.put("batch", batch["id"], batch)
continue continue
if (store.get("batch", batch["id"]) or {}).get("status") != "running": if (store.get("batch", batch["id"]) or {}).get("status") != "running":
@@ -1,20 +1,18 @@
"""Persist the single Responses endpoint without returning credentials to the browser.""" """Persist the single Responses endpoint without returning credentials to the browser."""
import os
import tempfile
import threading
from pathlib import Path
from typing import Literal from typing import Literal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from pydantic import BaseModel, Field, SecretStr, field_validator from pydantic import BaseModel, Field, SecretStr, field_validator
from artemis.config.agent import load_agent_config from artemis.config.agent import load_agent_config
from artemis.config.environment import (
persist_environment as write_environment,
TRANSPORT_DEFAULT_VERSION_KEY,
)
from artemis.config.paths import get_env_file from artemis.config.paths import get_env_file
from artemis.config.settings import settings from artemis.config.settings import settings
_write_lock = threading.Lock()
class ModelSettingsUpdate(BaseModel): class ModelSettingsUpdate(BaseModel):
base_url: str = "https://api-n-cd.playones.com/v1" base_url: str = "https://api-n-cd.playones.com/v1"
@@ -22,6 +20,7 @@ class ModelSettingsUpdate(BaseModel):
api_key: SecretStr | None = None # Omitted/blank keeps the existing key. api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
timeout_seconds: float = Field(default=300, gt=0, le=3600) timeout_seconds: float = Field(default=300, gt=0, le=3600)
max_retries: int = Field(default=5, ge=0, le=10) max_retries: int = Field(default=5, ge=0, le=10)
transport: Literal["http", "websocket"] | None = None
retry_interval_seconds: float = Field(default=10, ge=0, le=300) retry_interval_seconds: float = Field(default=10, ge=0, le=300)
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600) rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200) tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
@@ -72,6 +71,7 @@ def get_model_settings() -> dict:
"api_key_configured": bool(settings.OPENAI_API_KEY), "api_key_configured": bool(settings.OPENAI_API_KEY),
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
"max_retries": settings.OPENAI_MAX_RETRIES, "max_retries": settings.OPENAI_MAX_RETRIES,
"transport": settings.OPENAI_TRANSPORT,
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS, "retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS, "rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL, "tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
@@ -102,6 +102,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
"OPENAI_REASONING_EFFORT": update.reasoning_effort, "OPENAI_REASONING_EFFORT": update.reasoning_effort,
} }
optional_settings = { optional_settings = {
"transport": ("OPENAI_TRANSPORT", str),
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float), "rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str), "tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str), "tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
@@ -115,6 +116,8 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
value = getattr(update, field_name) value = getattr(update, field_name)
if value is not None: if value is not None:
values[env_name] = str(value) values[env_name] = str(value)
if update.transport is not None:
values[TRANSPORT_DEFAULT_VERSION_KEY] = "1"
if update.api_key and update.api_key.get_secret_value().strip(): if update.api_key and update.api_key.get_secret_value().strip():
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip() values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
persist_environment(values) persist_environment(values)
@@ -139,31 +142,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
def persist_environment(values: dict[str, str]) -> None: def persist_environment(values: dict[str, str]) -> None:
with _write_lock: write_environment(get_env_file(), values)
env_path = get_env_file()
env_path.parent.mkdir(parents=True, exist_ok=True)
original = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
# Preserve comments and unrelated variables; quote values to avoid dotenv interpolation.
kept = [
line
for line in original.splitlines()
if line.removeprefix("export ").split("=", 1)[0].strip() not in values
]
for key, value in values.items():
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
kept.append(f"{key}='{escaped}'")
content = "\n".join(kept) + "\n"
fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=env_path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_name, env_path)
finally:
Path(temp_name).unlink(missing_ok=True)
for key, value in values.items():
os.environ[key] = value
class CapabilitySettings(BaseModel): class CapabilitySettings(BaseModel):
@@ -261,10 +240,19 @@ async def test_model_settings(update: ModelSettingsUpdate) -> dict:
api_base=update.base_url, api_base=update.base_url,
timeout_seconds=update.timeout_seconds, timeout_seconds=update.timeout_seconds,
max_retries=0, max_retries=0,
transport=update.transport or settings.OPENAI_TRANSPORT,
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort, reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
) )
model = ModelFactory.create_model(endpoint) model = ModelFactory.create_model(endpoint)
try:
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")]) result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
finally:
await model.http_async_client.aclose()
model.http_client.close()
if not result.content: if not result.content:
raise ValueError("Responses 接口返回空内容,请检查模型配置") raise ValueError("Responses 接口返回空内容,请检查模型配置")
return {"success": True, "message": "Responses 连接测试成功", "model": update.model} return {
"success": True,
"message": f"Responses {endpoint.transport.upper()} 连接测试成功",
"model": update.model,
}
+33 -1
View File
@@ -36,7 +36,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result
from apps.admin_console.services.case_review import reviewed_result from apps.admin_console.services.case_review import reviewed_result
EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports"
BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3" BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-4"
def batch_archive_signature(batch_id: str | None = None) -> str: def batch_archive_signature(batch_id: str | None = None) -> str:
@@ -197,6 +197,7 @@ def session_report(session_id: str, db_path=None) -> dict:
identifier(session_id) identifier(session_id)
meta = store.job(session_id) or {} meta = store.job(session_id) or {}
batch = store.get("batch", meta["batch_id"]) if meta.get("batch_id") else None
repo = SessionRepository(db_path) if db_path else SessionRepository() repo = SessionRepository(db_path) if db_path else SessionRepository()
session = repo.get_session_by_id(session_id) or {} session = repo.get_session_by_id(session_id) or {}
if not session and not meta: if not session and not meta:
@@ -270,6 +271,15 @@ def session_report(session_id: str, db_path=None) -> dict:
"original_case": case.get("original_case"), "original_case": case.get("original_case"),
"task_config": case.get("task_config") or {}, "task_config": case.get("task_config") or {},
"task_extensions": task_extensions.report_sections(session_id, store), "task_extensions": task_extensions.report_sections(session_id, store),
"runtime_diagnostics": read_json(directory / "runtime_diagnostics.json"),
"batch_diagnostics": (
{
key: batch.get(key)
for key in ("id", "status", "reason", "pause_source", "recovery_events")
}
if batch
else None
),
} }
@@ -1081,6 +1091,17 @@ def export_session_media(session_id: str, kind: str, *, report: dict | None = No
) )
if kind == "all": if kind == "all":
archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}") archive.writestr(prefix + "模型请求诊断.json", diagnostics or "{}")
archive.writestr(
prefix + "运行环境诊断.json",
json.dumps(
{
"runtime": report.get("runtime_diagnostics"),
"batch": report.get("batch_diagnostics"),
},
ensure_ascii=False,
indent=2,
),
)
archive.writestr( archive.writestr(
prefix + "证据索引.json", prefix + "证据索引.json",
json.dumps( json.dumps(
@@ -1202,6 +1223,17 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
compress_type=compression(source), compress_type=compression(source),
) )
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"])) archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
archive.writestr(
f"{prefix}/运行环境诊断.json",
json.dumps(
{
"runtime": report.get("runtime_diagnostics"),
"batch": report.get("batch_diagnostics"),
},
ensure_ascii=False,
indent=2,
),
)
archive.writestr( archive.writestr(
f"{prefix}/证据索引.json", f"{prefix}/证据索引.json",
json.dumps( json.dumps(
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.6.0", "version": "2.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.6.0", "version": "2.7.1",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.4", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3", "@angular/common": "^22.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.6.0", "version": "2.7.1",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -30,6 +30,31 @@ describe('持久化模型与功能设置', () => {
http.expectNone('/api/system/model-settings'); http.expectNone('/api/system/model-settings');
})); }));
it('缺少协议的配置默认 WebSocket,允许手动切回 HTTP', fakeAsync(() => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/model-settings').flush(config);
tick();
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.transport).toBe('websocket');
const selector: HTMLSelectElement = fixture.nativeElement.querySelector('#model-transport');
selector.value = 'http';
selector.dispatchEvent(new Event('change'));
tick();
fixture.detectChanges();
component.test();
const check = http.expectOne('/api/system/model-settings/test');
expect(check.request.body.transport).toBe('http');
check.flush({message: 'Responses HTTP 连接测试成功'});
component.save();
const save = http.expectOne('/api/system/model-settings');
expect(save.request.body.transport).toBe('http');
expect(save.request.body.api_key).toBeUndefined();
save.flush({...config, transport: 'http'});
expect(component.transport).toBe('http');
}));
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => { it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
const fixture = TestBed.createComponent(ModelSettingsComponent); const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -8,6 +8,7 @@ import { ModelSelectorComponent } from '../model-selector/model-selector.compone
import { MODEL_PRESETS } from '../../core/model-presets'; import { MODEL_PRESETS } from '../../core/model-presets';
interface ModelSettings { interface ModelSettings {
transport?: 'http' | 'websocket';
base_url: string; base_url: string;
model: string; model: string;
api_key_configured: boolean; api_key_configured: boolean;
@@ -36,6 +37,12 @@ interface ModelSettings {
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required <input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" /> placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small> <small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-transport">传输协议</label>
<select id="model-transport" name="transport" [(ngModel)]="transport" [disabled]="busy() || loading()">
<option value="http">HTTP(兼容模式)</option>
<option value="websocket">WebSocket(增量传输)</option>
</select>
<small>WebSocket 可减少重复历史与截图的上传,需要接口支持。保存后对新任务生效。</small>
<app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" /> <app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
<small>选择预设模型,或选择“自定义模型”填写名称。</small> <small>选择预设模型,或选择“自定义模型”填写名称。</small>
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" /> <app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
@@ -102,6 +109,7 @@ interface ModelSettings {
`] `]
}) })
export class ModelSettingsComponent implements OnInit { export class ModelSettingsComponent implements OnInit {
transport: 'http' | 'websocket' = 'websocket';
readonly reasoningValues = REASONING_VALUES; readonly reasoningValues = REASONING_VALUES;
readonly reasoningLabels = REASONING_LABELS; readonly reasoningLabels = REASONING_LABELS;
private http = inject(HttpClient); private http = inject(HttpClient);
@@ -128,7 +136,7 @@ export class ModelSettingsComponent implements OnInit {
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10; && Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
} }
private payload() { private payload() {
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined, return { base_url: this.baseUrl.trim(), transport: this.transport, model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning, timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(), rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries, tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
@@ -136,6 +144,7 @@ export class ModelSettingsComponent implements OnInit {
} }
private apply(config: ModelSettings): void { private apply(config: ModelSettings): void {
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds; this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
this.transport = config.transport ?? 'websocket';
this.modelPresets = config.model_presets; this.modelPresets = config.model_presets;
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path); this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60; this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
+22 -15
View File
@@ -23,11 +23,15 @@ from artemis.context import ArtemisContext
from artemis.data_engine.trace import trace from artemis.data_engine.trace import trace
from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback from artemis.services.llm import get_llm, invoke_llm_with_timeout_message, with_fallback
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
logger = get_logger(__name__) logger = get_logger(__name__)
class HopperError(RuntimeError):
"""Extraction failed; this says nothing about whether the requested data exists."""
class _CyFunctionDetectorMeta(type): class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance): def __instancecheck__(self, instance):
name = type(instance).__name__ name = type(instance).__name__
@@ -55,6 +59,12 @@ class HopperOutput(BaseModel):
+ " and how you decided what to extract." + " and how you decided what to extract."
) )
@model_validator(mode="after")
def validate_found_output(self):
if self.found and (not self.output or not self.output.strip()):
raise ValueError("A successful extraction must contain a nonempty output")
return self
@trace(type="agent", name="hopper") @trace(type="agent", name="hopper")
async def hopper( async def hopper(
@@ -72,25 +82,22 @@ async def hopper(
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"), HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
] ]
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput) async def extract(model) -> HopperOutput:
response = await invoke_llm_with_timeout_message(model.ainvoke(messages))
return HopperOutput.model_validate(response)
try: try:
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
if use_fallback: if use_fallback:
llm_fallback = get_llm( llm_fallback = get_llm(
ctx=ctx, name="hopper", is_utils=True, use_fallback=True ctx=ctx, name="hopper", is_utils=True, use_fallback=True
).with_structured_output(HopperOutput) ).with_structured_output(HopperOutput)
response: HopperOutput = await with_fallback( return await with_fallback(
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)), main_call=lambda: extract(llm),
fallback_call=lambda: invoke_llm_with_timeout_message( fallback_call=lambda: extract(llm_fallback),
llm_fallback.ainvoke(messages) )
),
) # type: ignore
else: else:
response: HopperOutput = await invoke_llm_with_timeout_message(llm.ainvoke(messages)) return await extract(llm)
return response
except Exception as e: except Exception as e:
logger.error(f"Hopper LLM invocation failed: {e}") logger.error(f"Hopper LLM invocation failed: {e}")
return HopperOutput( raise HopperError("模型未能完成数据提取,请查看模型请求诊断。") from e
found=False,
output=None,
reason=f"Failed due to LLM error: {str(e)[:500]}",
)
+64
View File
@@ -0,0 +1,64 @@
"""Atomic environment persistence and the one-time WebSocket default migration."""
import os
from pathlib import Path
import tempfile
from dotenv import dotenv_values
from filelock import FileLock
TRANSPORT_DEFAULT_VERSION_KEY = "ARTEMIS_TRANSPORT_DEFAULT_VERSION"
def _lock(path: Path) -> FileLock:
path.parent.mkdir(parents=True, exist_ok=True)
return FileLock(str(path) + ".artemis.lock", timeout=5)
def _write(path: Path, values: dict[str, str]) -> None:
original = path.read_text(encoding="utf-8") if path.exists() else ""
kept = [
line
for line in original.splitlines()
if line.strip().removeprefix("export ").split("=", 1)[0].strip() not in values
]
for key, value in values.items():
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
kept.append(f"{key}='{escaped}'")
fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
stream.write("\n".join(kept) + "\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_name, path)
finally:
Path(temp_name).unlink(missing_ok=True)
def persist_environment(path: Path, values: dict[str, str]) -> None:
with _lock(path):
_write(path, values)
os.environ.update(values)
def _transport_default_is_current(path: Path) -> bool:
saved = dotenv_values(path, interpolate=False) if path.exists() else {}
try:
return int(saved.get(TRANSPORT_DEFAULT_VERSION_KEY) or "0") >= 1
except ValueError:
return False
def migrate_transport_default(path: Path) -> bool:
"""Migrate unversioned HTTP preferences once, preserving later opt-outs."""
# Completed migrations need no write access, including read-only deployments.
if _transport_default_is_current(path):
return False
with _lock(path):
if _transport_default_is_current(path):
return False
values = {"OPENAI_TRANSPORT": "websocket", TRANSPORT_DEFAULT_VERSION_KEY: "1"}
_write(path, values)
os.environ.update(values)
return True
+3
View File
@@ -62,6 +62,9 @@ class LLM(BaseModel):
provider: LLMProvider = "openai" provider: LLMProvider = "openai"
model: str = Field(default_factory=lambda: settings.OPENAI_MODEL) model: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
api_base: str | None = None api_base: str | None = None
transport: Literal["http", "websocket"] = Field(
default_factory=lambda: settings.OPENAI_TRANSPORT
)
timeout: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0) timeout: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0)
max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0) max_retries: int = Field(default_factory=lambda: settings.OPENAI_MAX_RETRIES, ge=0)
max_tokens: int | None = Field(default=None, gt=0) max_tokens: int | None = Field(default=None, gt=0)
+11 -2
View File
@@ -16,8 +16,10 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal
from dotenv import load_dotenv from dotenv import load_dotenv
from filelock import Timeout
from pydantic import Field, SecretStr, model_validator from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -31,6 +33,7 @@ from artemis.config.constants import (
ENV_OPENAI_API_KEY, ENV_OPENAI_API_KEY,
ENV_VISION_API_KEY, ENV_VISION_API_KEY,
) )
from artemis.config.environment import migrate_transport_default
from artemis.config.paths import ( from artemis.config.paths import (
GLOBAL_APP_DIR, GLOBAL_APP_DIR,
get_data_engine_db_path, get_data_engine_db_path,
@@ -42,13 +45,18 @@ from artemis.utils.logger import get_logger
# Installed wheels load .env from the user directory, outside site-packages. # Installed wheels load .env from the user directory, outside site-packages.
_canonical_env = get_env_file() _canonical_env = get_env_file()
logger = get_logger(__name__)
try:
if migrate_transport_default(_canonical_env):
logger.info("已将全局模型传输协议迁移为 WebSocket;之后可在模型配置中切回 HTTP。")
except (OSError, Timeout):
os.environ["OPENAI_TRANSPORT"] = "websocket"
logger.warning("无法保存 WebSocket 默认迁移;本次使用 WebSocket,下次启动重试。")
load_dotenv(dotenv_path=_canonical_env, verbose=True) load_dotenv(dotenv_path=_canonical_env, verbose=True)
_global_env = GLOBAL_APP_DIR / ".env" _global_env = GLOBAL_APP_DIR / ".env"
if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve(): if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve():
load_dotenv(dotenv_path=_global_env, verbose=True) load_dotenv(dotenv_path=_global_env, verbose=True)
logger = get_logger(__name__)
def is_placeholder_key(val: str | SecretStr | None) -> bool: def is_placeholder_key(val: str | SecretStr | None) -> bool:
"""Check if an API credential is empty or an unconfigured placeholder value.""" """Check if an API credential is empty or an unconfigured placeholder value."""
@@ -109,6 +117,7 @@ class Settings(BaseSettings):
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1" OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1) OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0) OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
OPENAI_TRANSPORT: Literal["http", "websocket"] = "websocket"
# Environment only; deliberately absent from the saved UI/task configuration. # Environment only; deliberately absent from the saved UI/task configuration.
OPENAI_USE_SYSTEM_PROXY: bool = False OPENAI_USE_SYSTEM_PROXY: bool = False
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10) OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
+7 -1
View File
@@ -36,6 +36,8 @@ from artemis.controllers.types import (
SwipeStartEndPercentagesRequest, SwipeStartEndPercentagesRequest,
TapOutput, TapOutput,
) )
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.toolchain import find_scrcpy
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
from artemis.utils.video import ( from artemis.utils.video import (
ANDROID_RECORDING_SEGMENT_SECONDS, ANDROID_RECORDING_SEGMENT_SECONDS,
@@ -82,9 +84,13 @@ class UnifiedMobileController:
@staticmethod @staticmethod
async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process: async def _spawn_scrcpy(command: list[str]) -> asyncio.subprocess.Process:
adb = AdbSession(current_adb_endpoint())
if not adb.adb_path:
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
"stdout": asyncio.subprocess.PIPE, "stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE, "stderr": asyncio.subprocess.PIPE,
"env": adb.environment(),
} }
if os.name == "nt": if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
@@ -606,7 +612,7 @@ class UnifiedMobileController:
config = recording_config(self.ctx) config = recording_config(self.ctx)
bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps bitrate = config.h265_kbps if codec == "h265" else config.h264_kbps
return build_scrcpy_record_command( return build_scrcpy_record_command(
"scrcpy", find_scrcpy(),
device_id, device_id,
path, path,
f"{bitrate}K", f"{bitrate}K",
+5
View File
@@ -39,6 +39,7 @@ from artemis.runtime.observation_budget import (
current_observation, current_observation,
) )
from artemis.toolchain import find_ffmpeg, find_scrcpy from artemis.toolchain import find_ffmpeg, find_scrcpy
from artemis.runtime.adb_endpoint import AdbSession, current_adb_endpoint
from artemis.utils.video import build_scrcpy_record_command from artemis.utils.video import build_scrcpy_record_command
from artemis.utils.ui_filter import filter_ui_hierarchy from artemis.utils.ui_filter import filter_ui_hierarchy
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
@@ -608,6 +609,9 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._recording_output_path = out_dir / "recording.mp4" self._recording_output_path = out_dir / "recording.mp4"
logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...") logger.info(f"Starting scrcpy video recording to {self._recording_mkv_path}...")
try: try:
adb = AdbSession(current_adb_endpoint())
if not adb.adb_path:
raise FileNotFoundError("Android Platform Tools (adb) could not be found.")
scrcpy_bin = find_scrcpy() scrcpy_bin = find_scrcpy()
cmd = build_scrcpy_record_command( cmd = build_scrcpy_record_command(
scrcpy_bin, scrcpy_bin,
@@ -617,6 +621,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
) )
self._scrcpy_process = await asyncio.create_subprocess_exec( self._scrcpy_process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
env=adb.environment(),
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
+7
View File
@@ -390,6 +390,11 @@ async def observe_response_headers(response):
"""Correlate allowlisted headers and passively observe bounded SSE frames.""" """Correlate allowlisted headers and passively observe bounded SSE frames."""
attempt = ACTIVE_ATTEMPT.get() attempt = ACTIVE_ATTEMPT.get()
if attempt is not None: if attempt is not None:
websocket = response.extensions.get("artemis_websocket", False)
if websocket:
# 200/SSE is an in-process SDK facade, not a wire HTTP response.
attempt.data.update(headers_at=time.time(), phase="waiting_output")
else:
attempt.headers(response) attempt.headers(response)
content_type = response.headers.get("content-type", "").split(";", 1)[0].lower() content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
if ( if (
@@ -407,5 +412,7 @@ async def observe_response_headers(response):
) )
response.stream = stream response.stream = stream
attempt.wire_streams.append(stream) attempt.wire_streams.append(stream)
if websocket:
stream.observation.data["observation_boundary"] = "websocket_event_sse_adapter"
else: else:
attempt.data["wire_streams_truncated"] = True attempt.data["wire_streams_truncated"] = True
+27 -4
View File
@@ -17,7 +17,7 @@
from enum import StrEnum from enum import StrEnum
import hashlib import hashlib
import os import os
from typing import Any from typing import Any, Literal
from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.language_models.chat_models import BaseChatModel
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -40,6 +40,9 @@ class ModelEndpoint(BaseModel):
model_name: str = Field(default_factory=lambda: settings.OPENAI_MODEL) model_name: str = Field(default_factory=lambda: settings.OPENAI_MODEL)
api_key: str | None = Field(default=None, repr=False) api_key: str | None = Field(default=None, repr=False)
api_base: str | None = None api_base: str | None = None
transport: Literal["http", "websocket"] = Field(
default_factory=lambda: settings.OPENAI_TRANSPORT
)
temperature: float | None = None temperature: float | None = None
max_tokens: int | None = Field(default=None, gt=0) max_tokens: int | None = Field(default=None, gt=0)
timeout_seconds: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0) timeout_seconds: float = Field(default_factory=lambda: settings.OPENAI_TIMEOUT_SECONDS, gt=0)
@@ -63,6 +66,7 @@ class ModelEndpoint(BaseModel):
self.enable_grounding, self.enable_grounding,
self.api_base or settings.OPENAI_BASE_URL, self.api_base or settings.OPENAI_BASE_URL,
settings.OPENAI_USE_SYSTEM_PROXY, settings.OPENAI_USE_SYSTEM_PROXY,
self.transport,
hashlib.sha256(key.encode()).hexdigest(), hashlib.sha256(key.encode()).hexdigest(),
) )
@@ -119,13 +123,32 @@ class ModelFactory:
"http_client": DefaultHttpxClient( "http_client": DefaultHttpxClient(
timeout=endpoint.timeout_seconds, **model_http_options() timeout=endpoint.timeout_seconds, **model_http_options()
), ),
"http_async_client": DefaultAsyncHttpxClient( }
if endpoint.transport == "websocket":
import httpx
from artemis.llm.websocket import ResponsesWebSocketTransport
# Disable HTTP proxy mounts on this facade: they would bypass the
# WebSocket transport. Each actual transport owns its proxy policy.
kwargs["http_async_client"] = httpx.AsyncClient(
timeout=endpoint.timeout_seconds,
trust_env=False,
transport=ResponsesWebSocketTransport(
timeout=endpoint.timeout_seconds, use_proxy=settings.OPENAI_USE_SYSTEM_PROXY
),
event_hooks={
"request": [observe_request_headers],
"response": [observe_response_headers],
},
)
else:
kwargs["http_async_client"] = DefaultAsyncHttpxClient(
timeout=endpoint.timeout_seconds, timeout=endpoint.timeout_seconds,
**model_http_options(), **model_http_options(),
event_hooks={ event_hooks={
"request": [observe_request_headers], "request": [observe_request_headers],
"response": [observe_response_headers], "response": [observe_response_headers],
}, },
), )
}
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None}) return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
+449
View File
@@ -0,0 +1,449 @@
"""Responses WebSocket transport beneath the existing SDK and safety guards.
The SDK still serializes complete, authoritative requests. Only this wire boundary
omits a verified prefix. No model messages, tool results, or screenshots are lost
from local history, so any new connection can start from the full request.
"""
import asyncio
from collections import OrderedDict
from contextlib import suppress
import hashlib
import json
import time
from uuid import uuid4
import httpx
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, InvalidStatus, SecurityError
from websockets.protocol import State
from artemis.llm.diagnostics import ACTIVE_ATTEMPT
from artemis.llm.http import model_http_options
from artemis.llm.responses_diagnostics import identifier
IDLE_SECONDS = 60
MAX_SESSIONS = 32
MAX_CONNECTION_SECONDS = 55 * 60
def _json(value):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def _fingerprint(item):
# Assistant message IDs/status are transport metadata; store=false strips
# message IDs on replay. Keep every semantic field, including call_id/phase.
if isinstance(item, dict):
item = dict(item)
item.pop("status", None)
if item.get("type") == "message" or item.get("role") == "assistant":
item.pop("id", None)
item.setdefault("type", "message")
# Completion events may include token probabilities; LangChain
# correctly omits those response-only statistics from replay input.
if isinstance(item.get("content"), list):
item["content"] = [
{k: v for k, v in block.items() if k != "logprobs"}
if isinstance(block, dict) and block.get("type") == "output_text"
else block
for block in item["content"]
]
if item.get("type") == "function_call" and isinstance(item.get("arguments"), str):
with suppress(ValueError):
item["arguments"] = json.loads(item["arguments"])
return hashlib.sha256(_json(item).encode()).digest()
class _Connect(connect):
def process_redirect(self, exc):
# Never forward the API credential to a redirected endpoint.
if not isinstance(exc, InvalidStatus) or exc.response.status_code not in {
301,
302,
303,
307,
308,
}:
return exc
return SecurityError("Responses WebSocket redirects are disabled")
class _Session:
def __init__(self):
self.lock = asyncio.Lock()
self.users = 0
self.connection = None
self.opened_at = 0.0
self.previous_id = None
self.history = ()
self.options = None
self.timer = None
self.handshake = {}
async def close(self):
if self.timer:
self.timer.cancel()
self.timer = None
self.previous_id = None
self.history = ()
self.options = None
connection, self.connection = self.connection, None
if connection is not None:
with suppress(ConnectionClosed, OSError, TimeoutError):
async with asyncio.timeout(1):
await connection.close()
class ResponsesWebSocketTransport(httpx.AsyncBaseTransport):
"""One serial socket per conversation/role, independent sockets for concurrency."""
def __init__(self, *, timeout, use_proxy):
self.timeout = timeout
self.use_proxy = use_proxy
self.http = httpx.AsyncClient(timeout=timeout, **model_http_options())
self.sessions = OrderedDict()
self.cleanup_tasks = set()
async def aclose(self):
sessions = list(self.sessions.values())
self.sessions.clear()
await asyncio.gather(*(session.close() for session in sessions))
if self.cleanup_tasks:
await asyncio.gather(*tuple(self.cleanup_tasks), return_exceptions=True)
await self.http.aclose()
def _close_background(self, session):
task = asyncio.create_task(session.close())
self.cleanup_tasks.add(task)
task.add_done_callback(self.cleanup_tasks.discard)
async def _session(self, request, body):
key = body.get("prompt_cache_key")
# Header identity prevents credentials, routing, and different gateways
# from sharing a socket. Per-request correlation headers aren't affinity.
headers = {
k: v
for k, v in request.headers.items()
if k not in {"content-length", "x-client-request-id", "x-client-attempt-id"}
}
identity = hashlib.sha256(_json([str(request.url), key, headers]).encode()).digest()
reusable = isinstance(key, str) and bool(key)
if reusable and identity in self.sessions:
session = self.sessions.pop(identity)
self.sessions[identity] = session
else:
if len(self.sessions) >= MAX_SESSIONS:
for old_key, old in list(self.sessions.items()):
if not old.users:
del self.sessions[old_key]
# Reserve the replacement before yielding: concurrent
# requests for its scope must find the same session.
self._close_background(old)
break
session = _Session()
reusable = reusable and len(self.sessions) < MAX_SESSIONS
if reusable:
self.sessions[identity] = session
acquired = False
session.users += 1
try:
await session.lock.acquire()
acquired = True
finally:
if not acquired:
session.users -= 1
self._idle(identity, session, reusable)
if session.timer:
session.timer.cancel()
session.timer = None
return identity, session, reusable
def release(self, identity, session, reusable):
session.lock.release()
session.users -= 1
self._idle(identity, session, reusable)
def _idle(self, identity, session, reusable):
if not reusable or session.users or self.sessions.get(identity) is not session:
return
def expire():
if session.users or self.sessions.get(identity) is not session:
return
del self.sessions[identity]
self._close_background(session)
session.timer = asyncio.get_running_loop().call_later(IDLE_SECONDS, expire)
async def handle_async_request(self, request):
if request.method != "POST" or not request.url.path.rstrip("/").endswith("/responses"):
return await self.http.send(request, stream=True)
body = json.loads(await request.aread())
identity, session, reusable = await self._session(request, body)
exchange = _Exchange(self, request, body, identity, session, reusable)
stream_handed_off = False
try:
await exchange.start()
first = await exchange.read()
if first.get("type") == "error":
status = first.get("status", 400)
status = status if type(status) is int and 400 <= status <= 599 else 400
return httpx.Response(status, json={"error": first.get("error", {})})
if not body.get("stream"):
event = first
while not exchange.done:
event = await exchange.read()
if event.get("type") == "error":
raise httpx.ReadError("Responses WebSocket request failed", request=request)
return httpx.Response(
200, json=event["response"], extensions={"artemis_websocket": True}
)
response = httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=_EventStream(exchange, first),
extensions={"artemis_websocket": True},
)
stream_handed_off = True
return response
except InvalidStatus as error:
return httpx.Response(
error.response.status_code,
json={
"error": {
"message": "Responses WebSocket 升级失败,请检查网关 WebSocket 支持或选择 HTTP。"
}
},
headers={
k: v
for k, v in error.response.headers.raw_items()
if k.lower() in {"retry-after", "x-request-id"}
},
)
finally:
if not stream_handed_off:
await exchange.aclose()
class _Exchange:
def __init__(self, transport, request, body, identity, session, reusable):
self.transport = transport
self.request = request
self.body = body
self.identity = identity
self.session = session
self.reusable = reusable
self.done = False
self.closed = False
self.recovered = False
self.output_seen = False
self.input = body.get("input")
self.input_hashes = (
tuple(map(_fingerprint, self.input)) if isinstance(self.input, list) else ()
)
self.options = hashlib.sha256(
_json(
{
k: v
for k, v in body.items()
if k not in {"input", "stream", "background", "previous_response_id"}
}
).encode()
).digest()
self.explicit_state = bool(body.get("previous_response_id") or body.get("conversation"))
self.attempt = ACTIVE_ATTEMPT.get()
self.stats = {
"mode": "websocket",
"full_request_bytes": len(request.content),
"sent_payload_bytes": 0,
"received_payload_bytes": 0,
"create_events": 0,
"incremental": False,
"reused_input_items": 0,
"connection_reused": session.connection is not None,
}
if self.attempt is not None:
self.attempt.data["transport"] = self.stats
async def start(self):
session = self.session
if session.connection is not None and (
session.connection.state is not State.OPEN
or time.monotonic() - session.opened_at >= MAX_CONNECTION_SECONDS
):
await session.close()
self.stats["connection_reused"] = False
if session.connection is None:
url = self.request.url.copy_with(
scheme="wss" if self.request.url.scheme == "https" else "ws"
)
headers = {
k: v
for k, v in self.request.headers.items()
if k
not in {"host", "content-length", "content-type", "connection", "accept-encoding"}
}
options = {"ssl": model_http_options()["verify"]} if url.scheme == "wss" else {}
session.connection = await _Connect(
str(url),
additional_headers=headers,
user_agent_header=None,
proxy=True if self.transport.use_proxy else None,
open_timeout=self.transport.timeout,
close_timeout=1,
max_size=64 * 1024 * 1024,
**options,
)
session.opened_at = time.monotonic()
session.handshake = {
"client_request_id": identifier(headers.get("x-client-request-id")),
"client_attempt_id": identifier(headers.get("x-client-attempt-id")),
}
if self.attempt:
self.attempt.data.update(session.handshake, request_header_scope="websocket_handshake")
for field, local in (
("client_request_id", "request_id"),
("client_attempt_id", "attempt_id"),
):
self.attempt.data[field + "_matches"] = session.handshake.get(
field
) == self.attempt.data.get(local)
self.stats["handshake_http_status"] = 101
await self.send(incremental=True)
async def send(self, *, incremental):
session = self.session
payload = {k: v for k, v in self.body.items() if k not in {"stream", "background"}}
count = len(session.history)
can_continue = (
incremental
and not self.explicit_state
and session.previous_id
and session.options == self.options
and count
and len(self.input_hashes) > count
and self.input_hashes[:count] == session.history
)
if can_continue:
payload["previous_response_id"] = session.previous_id
payload["input"] = self.input[count:]
self.stats.update(incremental=True, reused_input_items=count)
else:
self.stats.update(incremental=False, reused_input_items=0)
self.stats["full_context_reason"] = (
"explicit_state"
if self.explicit_state
else "history_or_options_changed"
if session.previous_id
else "new_connection_or_chain"
)
payload["type"] = "response.create"
encoded = _json(payload)
await session.connection.send(encoded)
self.stats["sent_payload_bytes"] += len(encoded.encode())
self.stats["create_events"] += 1
self.stats["payload_bytes_saved"] = max(
0, self.stats["full_request_bytes"] - self.stats["sent_payload_bytes"]
)
if self.attempt:
self.attempt.data["request_sent_at"] = time.time()
self.attempt.publish_if_due()
async def read(self):
try:
async with asyncio.timeout(self.transport.timeout):
raw = await self.session.connection.recv()
self.stats["received_payload_bytes"] += len(
raw.encode() if isinstance(raw, str) else raw
)
event = json.loads(raw)
if not isinstance(event, dict):
raise ValueError("Responses WebSocket event is not an object")
except ConnectionClosed as error:
raise httpx.ReadError(
"Responses WebSocket connection closed before completion", request=self.request
) from error
except (ValueError, UnicodeError) as error:
raise httpx.ReadError(
"Invalid Responses WebSocket event", request=self.request
) from error
kind = event.get("type", "")
error = event.get("error") or {}
if (
kind == "error"
and error.get("code") == "previous_response_not_found"
and self.stats["incremental"]
and not self.recovered
and not self.output_seen
):
# An explicit rejection means no model output was generated. Recover
# once from the authoritative input; never replay an ambiguous drop.
self.recovered = True
self.session.previous_id = None
self.stats["chain_recovered"] = True
await self.send(incremental=False)
return await self.read()
if kind not in {"error", "response.created", "response.in_progress", "response.queued"}:
self.output_seen = True
if kind in {
"response.completed",
"response.failed",
"response.incomplete",
"response.cancelled",
}:
self.done = True
response = event.get("response") or {}
self.session.previous_id = None
if (
kind == "response.completed"
and response.get("status") == "completed"
and isinstance(response.get("id"), str)
and response["id"]
and isinstance(response.get("output"), list)
and self.input_hashes
and not self.explicit_state
):
output = [
item
for item in response["output"]
if not (
item.get("type") == "reasoning"
and not item.get("encrypted_content")
and self.body.get("store") is False
)
]
self.session.previous_id = response["id"]
self.session.history = self.input_hashes + tuple(map(_fingerprint, output))
self.session.options = self.options
return event
async def aclose(self):
if self.closed:
return
self.closed = True
try:
if not self.done or not self.reusable:
await self.session.close()
finally:
self.transport.release(self.identity, self.session, self.reusable)
class _EventStream(httpx.AsyncByteStream):
def __init__(self, exchange, first):
self.exchange = exchange
self.first = first
async def __aiter__(self):
try:
event = self.first
while True:
yield b"data: " + _json(event).encode() + b"\n\n"
if self.exchange.done or event.get("type") == "error":
return
event = await self.exchange.read()
finally:
await self.aclose()
async def aclose(self):
await self.exchange.aclose()
+1
View File
@@ -2,6 +2,7 @@
{ {
"default": { "default": {
"provider": "openai", "provider": "openai",
"transport": "${env:OPENAI_TRANSPORT:-websocket}",
"model": "${env:OPENAI_MODEL:-gpt-6-luna}", "model": "${env:OPENAI_MODEL:-gpt-6-luna}",
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}", "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}", "max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
+2
View File
@@ -167,6 +167,8 @@ class AdbSession:
def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]: def environment(self, base: Mapping[str, str] | None = None) -> dict[str, str]:
environment = dict(base if base is not None else os.environ) environment = dict(base if base is not None else os.environ)
self.endpoint.apply_to_environment(environment) self.endpoint.apply_to_environment(environment)
if self.adb_path:
environment["ADB"] = self.adb_path
return environment return environment
def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess: def run(self, arguments: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess:
+207
View File
@@ -0,0 +1,207 @@
"""Small, task-owned runtime snapshots; no polling or readiness decisions."""
import asyncio
import ctypes
from ctypes import wintypes
from functools import lru_cache
import hashlib
import json
import logging
import os
from pathlib import Path
import platform
import subprocess
import sys
import time
import psutil
from artemis._version import get_version
from artemis.config.paths import ROOT_DIR
from artemis.runtime.adb_endpoint import current_adb_endpoint
from artemis.toolchain import toolchain
logger = logging.getLogger(__name__)
FILENAME = "runtime_diagnostics.json"
def _command(arguments: list[str]) -> str | None:
try:
result = subprocess.run(
arguments,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=3,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
check=False,
)
return result.stdout.strip()[:4096] if result.returncode == 0 else None
except (OSError, subprocess.SubprocessError):
return None
def _tool_identity(name: str) -> dict:
path = toolchain.resolve(name)
result = {"path": path, "version": None, "sha256": None}
if path:
result["version"] = _command([path, "version" if name == "adb" else "--version"])
try:
with Path(path).open("rb") as stream:
result["sha256"] = hashlib.file_digest(stream, "sha256").hexdigest()
except OSError:
logger.debug("Could not hash %s", name)
return result
@lru_cache(maxsize=2)
def runtime_identity(android: bool) -> dict:
"""Read identity once per worker, before later source updates can change it."""
git = ["git", "-C", str(ROOT_DIR)]
checkout = (ROOT_DIR / ".git").exists()
commit = _command([*git, "rev-parse", "--verify", "HEAD"]) if checkout else None
changes = (
_command([*git, "status", "--porcelain", "--untracked-files=no"]) if checkout else None
)
return {
"artemis_version": get_version(),
"source_commit": commit,
"source_modified": bool(changes) if changes is not None else None,
"installation_archive_sha256": None,
"python_version": platform.python_version(),
"platform": platform.system(),
"tools": {name: _tool_identity(name) for name in ("adb", "scrcpy")} if android else {},
}
class _PerformanceInformation(ctypes.Structure):
# https://learn.microsoft.com/windows/win32/api/psapi/ns-psapi-performance_information
_fields_ = (
[("cb", wintypes.DWORD)]
+ [
(name, ctypes.c_size_t)
for name in (
"CommitTotal",
"CommitLimit",
"CommitPeak",
"PhysicalTotal",
"PhysicalAvailable",
"SystemCache",
"KernelTotal",
"KernelPaged",
"KernelNonpaged",
"PageSize",
)
]
+ [(name, wintypes.DWORD) for name in ("HandleCount", "ProcessCount", "ThreadCount")]
)
def _windows_commit() -> dict | None:
if sys.platform != "win32":
return None
values = _PerformanceInformation()
values.cb = ctypes.sizeof(values)
query = ctypes.WinDLL("psapi", use_last_error=True).GetPerformanceInfo
query.argtypes = [ctypes.POINTER(_PerformanceInformation), wintypes.DWORD]
query.restype = wintypes.BOOL
if not query(ctypes.byref(values), values.cb):
raise ctypes.WinError(ctypes.get_last_error())
committed = values.CommitTotal * values.PageSize
limit = values.CommitLimit * values.PageSize
return {
"committed_bytes": committed,
"limit_bytes": limit,
"available_bytes": max(0, limit - committed),
"note": "系统提交额度,不能当作页面文件大小。",
}
def _memory_snapshot() -> dict:
result = {"physical": None, "system_commit": None, "worker": None}
try:
memory = psutil.virtual_memory()
result["physical"] = {"total_bytes": memory.total, "available_bytes": memory.available}
result["system_commit"] = _windows_commit()
except (OSError, psutil.Error):
logger.debug("Host memory snapshot unavailable")
try:
memory = psutil.Process().memory_info()
result["worker"] = {
"pid": os.getpid(),
"rss_bytes": memory.rss,
"private_bytes": getattr(memory, "private", None),
}
except (OSError, psutil.Error):
logger.debug("Worker memory snapshot unavailable")
return result
def _adb_server() -> dict:
endpoint = current_adb_endpoint()
result = {"endpoint": endpoint.to_dict(), "local_listener": None}
if endpoint.host.lower() not in {"localhost", "127.0.0.1", "::1"}:
return result # A remote server's process cannot be observed on this host.
try:
for connection in psutil.net_connections(kind="tcp"):
if (
connection.status == psutil.CONN_LISTEN
and connection.laddr.port == endpoint.port
and connection.laddr.ip
in {
endpoint.host,
"0.0.0.0",
"::",
*(("127.0.0.1", "::1") if endpoint.host.lower() == "localhost" else ()),
}
and connection.pid
):
process = psutil.Process(connection.pid)
result["local_listener"] = {
"pid": process.pid,
"name": process.name(),
"path": process.exe(),
}
break
except (OSError, psutil.Error):
logger.debug("ADB listener identity unavailable")
return result
def write_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
"""Keep a start snapshot and the latest failure snapshot without changing task results."""
if phase not in {"start", "failure"}:
raise ValueError("Unknown runtime snapshot phase")
path = directory / FILENAME
try:
payload = {"schema_version": 1}
if path.is_file() and path.stat().st_size <= 256 * 1024:
try:
saved = json.loads(path.read_text(encoding="utf-8"))
if isinstance(saved, dict):
payload = saved
except (OSError, ValueError):
logger.debug("Previous runtime snapshot unreadable")
if phase == "start" and phase in payload:
return
payload[phase] = {
"captured_at": time.time(),
"runtime": runtime_identity(android),
"memory": _memory_snapshot(),
"adb_server": _adb_server() if android else None,
}
directory.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
except (OSError, psutil.Error, ValueError, TypeError):
logger.warning("Could not save runtime snapshot (%s)", phase, exc_info=True)
async def capture_runtime_diagnostics(directory: Path, phase: str, *, android: bool = True) -> None:
"""Keep optional evidence failures outside the task's execution result."""
try:
await asyncio.to_thread(write_runtime_diagnostics, directory, phase, android=android)
except (OSError, RuntimeError):
logger.warning("Could not start runtime snapshot (%s)", phase, exc_info=True)
+15
View File
@@ -654,6 +654,13 @@ class Agent:
# publish a new active session while the current task is still # publish a new active session while the current task is still
# finishing. # finishing.
self._prepare_tracing(task=task, context=context) self._prepare_tracing(task=task, context=context)
from artemis.runtime.diagnostics import capture_runtime_diagnostics
await capture_runtime_diagnostics(
context.data_engine.base_dir,
"start",
android=request.execution_target != "computer",
)
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1": if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
from artemis.runtime.task_control import TaskControl from artemis.runtime.task_control import TaskControl
@@ -1333,6 +1340,14 @@ class Agent:
async def _finalize_tracing(self, task: Task, context: ArtemisContext): async def _finalize_tracing(self, task: Task, context: ArtemisContext):
if context.data_engine: if context.data_engine:
if task.status == "failed":
from artemis.runtime.diagnostics import capture_runtime_diagnostics
await capture_runtime_diagnostics(
context.data_engine.base_dir,
"failure",
android=context.execution_target != "computer",
)
session_status = "completed" session_status = "completed"
if task.status == "failed": if task.status == "failed":
session_status = "failed" session_status = "failed"
+9 -3
View File
@@ -748,10 +748,15 @@ class RobustChatModelWrapper:
if observation is not None and (usage := usage_from_message(response)) is not None: if observation is not None and (usage := usage_from_message(response)) is not None:
observation.data["usage"] = usage observation.data["usage"] = usage
ensure_response_complete(response) ensure_response_complete(response)
if self.structured_mode == "parsed" and isinstance(response, dict) and "raw" in response: if self.structured_mode == "parsed":
if isinstance(response, dict) and "raw" in response:
if response.get("parsing_error") is not None: if response.get("parsing_error") is not None:
raise response["parsing_error"] raise ResponseIntegrityError("invalid_structured_output") from response[
return response["parsed"] "parsing_error"
]
response = response.get("parsed")
if response is None:
raise ResponseIntegrityError("missing_structured_output")
return response return response
async def ainvoke(self, *args, **kwargs): async def ainvoke(self, *args, **kwargs):
@@ -1174,6 +1179,7 @@ def _resolve_endpoint(
else settings.OPENAI_MAX_RETRIES, else settings.OPENAI_MAX_RETRIES,
max_tokens=_get_val(cfg, "max_tokens", int), max_tokens=_get_val(cfg, "max_tokens", int),
api_base=_get_val(cfg, "api_base", str), api_base=_get_val(cfg, "api_base", str),
transport=_get_val(cfg, "transport", str) or settings.OPENAI_TRANSPORT,
reasoning_effort=_get_val(cfg, "reasoning_effort", str), reasoning_effort=_get_val(cfg, "reasoning_effort", str),
enable_grounding=_get_val(cfg, "enable_grounding", bool) or False, enable_grounding=_get_val(cfg, "enable_grounding", bool) or False,
) )
+5 -7
View File
@@ -18,7 +18,7 @@ from langchain_core.messages import ToolMessage
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from artemis.agents.hopper.hopper import HopperOutput, hopper from artemis.agents.hopper.hopper import HopperError, HopperOutput, hopper
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.controllers.platform_specific_commands_controller import ( from artemis.controllers.platform_specific_commands_controller import (
list_packages_async, list_packages_async,
@@ -53,7 +53,7 @@ LAUNCH_APP_DOCSTRING = (
async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None: async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool = True) -> str | None:
"""Finds the package name for a given application name. """Finds the package name for a given application name.
Returns None if package not found or on error. Returns None if no installed package matches. Model failures raise HopperError.
""" """
package_cache = getattr(ctx, "package_cache", None) package_cache = getattr(ctx, "package_cache", None)
if package_cache is None or not isinstance(package_cache, dict): if package_cache is None or not isinstance(package_cache, dict):
@@ -63,7 +63,7 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
except Exception: # pylint: disable=broad-exception-caught except Exception: # pylint: disable=broad-exception-caught
package_cache = {} package_cache = {}
if isinstance(package_cache, dict) and app_name in package_cache: if isinstance(package_cache, dict) and package_cache.get(app_name):
logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}") logger.info(f"Cache hit for app '{app_name}': {package_cache[app_name]}")
return package_cache[app_name] return package_cache[app_name]
@@ -84,8 +84,6 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
use_fallback=use_fallback, use_fallback=use_fallback,
) )
if not hopper_output.found or not hopper_output.output: if not hopper_output.found or not hopper_output.output:
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None return None
package_name = hopper_output.output.strip() package_name = hopper_output.output.strip()
@@ -94,13 +92,13 @@ async def find_package(ctx: ArtemisContext, app_name: str, use_fallback: bool =
f"Hopper returned package '{package_name}' for '{app_name}', " f"Hopper returned package '{package_name}' for '{app_name}', "
"but it is NOT physically installed on the device!" "but it is NOT physically installed on the device!"
) )
if isinstance(package_cache, dict):
package_cache[app_name] = None
return None return None
if isinstance(package_cache, dict): if isinstance(package_cache, dict):
package_cache[app_name] = package_name package_cache[app_name] = package_name
return package_name return package_name
except HopperError:
raise
except Exception as e: # pylint: disable=broad-exception-caught except Exception as e: # pylint: disable=broad-exception-caught
logger.error(f"Failed to find package for '{app_name}': {e}") logger.error(f"Failed to find package for '{app_name}': {e}")
return None return None
+1
View File
@@ -2,6 +2,7 @@
{ {
"default": { "default": {
"provider": "openai", "provider": "openai",
"transport": "${env:OPENAI_TRANSPORT:-websocket}",
"model": "${env:OPENAI_MODEL:-gpt-6-luna}", "model": "${env:OPENAI_MODEL:-gpt-6-luna}",
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}", "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}", "max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
+14
View File
@@ -169,6 +169,20 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
辅助摘要继续独立处理各自片段,避免为了提高命中比例累积不相关的历史。视觉摘要中完全重复的插件执行结果与控件树只发送一份;独立错误、状态、不同观察及原始 trace 均保留。 辅助摘要继续独立处理各自片段,避免为了提高命中比例累积不相关的历史。视觉摘要中完全重复的插件执行结果与控件树只发送一份;独立错误、状态、不同观察及原始 trace 均保留。
## Responses WebSocket 增量传输
模型传输默认 `websocket`。首次启动时,未带迁移标记的全局配置(包括已保存的 `OPENAI_TRANSPORT=http`)统一切换为 WebSocket,并在安装模式对应的 `.env` 原子写入 `ARTEMIS_TRANSPORT_DEFAULT_VERSION=1`。迁移只调整协议及此内部标记,保留凭据、接口地址、其他设置和注释;跨进程文件锁避免启动与网页保存相互覆盖。保存失败保留原文件、明确告警,本次进程仍使用 WebSocket,下次启动重试。
之后可在“模型配置 → 传输协议”手动切回 `HTTP(兼容模式)`;保存会保留迁移标记,不会在重启时再次改成 WebSocket。直接编辑 `.env` 时保留 `ARTEMIS_TRANSPORT_DEFAULT_VERSION=1`,设置 `OPENAI_TRANSPORT=http` 后重启即可切回。网页保存对新任务生效;升级迁移不会改动正在运行的任务。JSON 模型/节点配置仍可用 `transport` 单独覆盖。MCP 使用 `mobile_config(section="model", changes={"transport":"websocket"}, persist_defaults=true)` 保存,仍须用户明确要求修改默认值。
异步任务请求在现有 SDK 序列化与完整性保护之下接入 `/v1/responses` WebSocket。首轮发送完整上下文;同一任务(或探索)、角色、模型、路由头及凭据范围内复用连接。只在当前输入严格包含上一轮输入和完整输出、生成选项一致时,发送 `previous_response_id` 与新增输入。仅忽略消息 ID、状态、输出概率等不影响历史语义的响应字段,不按文本相似度截取。工具结果保留 `call_id`,文本、图片、结构化输出与 usage 继续走原有解析和安全检查。同步底层模型调用仍使用 HTTP;Flash、Pro、探索和辅助模型运行路径均为异步。
本地历史、截图替换或压缩后重新发送当前完整上下文;不会为了续传保留已经移除的旧界面索引。连接重建或服务端明确返回 `previous_response_not_found` 时,从本地完整历史恢复,后者最多补发一次且必须尚未出现输出。连接在输出中途断开时,不在传输层盲目重发,交给现有模型重试预算;取消关闭未完成连接,不采纳半截工具参数或结论。不同会话并行、同一连接串行;连接池最多保留 32 个会话,空闲 60 秒释放,连接满 55 分钟主动重建。`store=false` 保持不变。
网关必须支持 WebSocket Upgrade、双向长连接、`response.create` 与响应 ID 续接;只透传会话头不能证明支持。握手传递现有授权和路由头,保持 TLS/自定义 CA 验证与 `OPENAI_USE_SYSTEM_PROXY` 策略,拒绝携带凭据跨地址重定向。不支持升级时明确报错,可在网页改回 HTTP,不会静默改成 HTTP 全量请求。
请求诊断的 `transport` 记录 `full_request_bytes`、`sent_payload_bytes`、`received_payload_bytes`、`payload_bytes_saved`、`incremental`、`reused_input_items`、`connection_reused`、`create_events` 和重建原因。字节数衡量 JSON 应用载荷,不包含 TLS/WebSocket 帧和压缩开销;SDK 内部兼容 SSE 的字节不冒充网络流量或 HTTP 200。握手上的请求标识属于建连尝试,后续轮次以本地请求诊断及响应 ID 关联。减少上传不等于减少模型上下文 token,也不等于缓存命中,usage 仍以服务端实报为准。协议见 [OpenAI 官方说明](https://developers.openai.com/api/docs/guides/websocket-mode)。
## 指定任务诊断、复盘与管理 ## 指定任务诊断、复盘与管理
流式请求中的 `click`、`long_press`、`swipe`、`press_key`、`manage_app`、`wait_for_delay` 每次调用参数超过 16,384 字符时,会立即关闭该请求并丢弃未完成输出,避免持续生成异常参数直到超时。该保护只针对小型设备动作,不限制输入文本、笔记、报告或插件参数,也不改变模型思考强度与输出 token 配置。异常最多额外尝试一次(配置为不重试时不重试);仍异常时进入现有备用模型或暂停恢复流程。诊断记录保留 `invalid_output` 分类及 `stream_guard` 中的工具名、实际字符数和保护上限,不保存参数正文;无法据此断定异常来自模型生成还是 API 中转。 流式请求中的 `click`、`long_press`、`swipe`、`press_key`、`manage_app`、`wait_for_delay` 每次调用参数超过 16,384 字符时,会立即关闭该请求并丢弃未完成输出,避免持续生成异常参数直到超时。该保护只针对小型设备动作,不限制输入文本、笔记、报告或插件参数,也不改变模型思考强度与输出 token 配置。异常最多额外尝试一次(配置为不重试时不重试);仍异常时进入现有备用模型或暂停恢复流程。诊断记录保留 `invalid_output` 分类及 `stream_guard` 中的工具名、实际字符数和保护上限,不保存参数正文;无法据此断定异常来自模型生成还是 API 中转。
+2
View File
@@ -50,6 +50,8 @@ Flash 开关保存在全局配置中,网页切换后自动保存,失败恢
诊断可单独下载,也会收录到单任务“全部资料”和批次 ZIP 各任务目录的 `模型请求诊断.json`。请求状态复用任务 trace 数据库,每次尝试一条记录;同一阶段的流式更新至多每 5 秒保存一次,阶段变化立即记录,不额外调用模型、抓取屏幕或记录认证头。强制退出导致缺少结束事件时保留最后阶段,不推断为成功。 诊断可单独下载,也会收录到单任务“全部资料”和批次 ZIP 各任务目录的 `模型请求诊断.json`。请求状态复用任务 trace 数据库,每次尝试一条记录;同一阶段的流式更新至多每 5 秒保存一次,阶段变化立即记录,不额外调用模型、抓取屏幕或记录认证头。强制退出导致缺少结束事件时保留最后阶段,不推断为成功。
新任务还会在开始和失败收尾时保存一次运行环境快照,单任务“全部资料”和批次 ZIP 各任务目录收录为 `运行环境诊断.json`。内容包括 ARTEMIS/Python 版本、可取得的源码提交与本地修改标记、实际解析的 ADB/scrcpy 路径、版本与 SHA256,以及采集时的内存和本机 ADB 监听进程信息;仅 PC 任务不查询安卓工具。Windows 的系统提交量、提交上限与余量按字节记录,不代表页面文件单独大小。快照不持续监控、不控制任务派发,也不能还原采集间隔内的内存变化;远程 ADB 进程、未保留的安装 ZIP 哈希及旧任务缺失字段均保留未知,导出时不会用当前环境补写历史。批次重启仍需手动继续,原暂停原因保留,恢复事件另行留档。
Flash、Pro 和探索的最终验收使用独立总预算,不再复用中间检查点的 180 秒时限。预算按实际 Checker 的单次请求超时 `T`、重试次数 `R`,以及普通/限流重试间隔的较大值 `D` 自动计算:`min(3600, T × (3 + R) + D × R + 60)` 秒。它为取证、结论及必要的结构化整理预留三次正常请求时间,再共用一份重试时间余量和 60 秒工具读取余量;不会按 20 轮循环逐轮放大。每个请求仍最多重试 `R` 次,但全部请求和重试必须在总预算内完成。默认配置的上限为 2760 秒,提前完成就立即结束;用户停止、探索指导及任务本身的执行时限仍生效。 Flash、Pro 和探索的最终验收使用独立总预算,不再复用中间检查点的 180 秒时限。预算按实际 Checker 的单次请求超时 `T`、重试次数 `R`,以及普通/限流重试间隔的较大值 `D` 自动计算:`min(3600, T × (3 + R) + D × R + 60)` 秒。它为取证、结论及必要的结构化整理预留三次正常请求时间,再共用一份重试时间余量和 60 秒工具读取余量;不会按 20 轮循环逐轮放大。每个请求仍最多重试 `R` 次,但全部请求和重试必须在总预算内完成。默认配置的上限为 2760 秒,提前完成就立即结束;用户停止、探索指导及任务本身的执行时限仍生效。
页面在验收期间单独显示证据准备/核对/读取、结论整理、完整性校验阶段,以及已用时间、剩余总预算、请求与实际重试次数。诊断 JSON 的 `final_reviews` 记录每次验收的预算、截止时间和结束原因,相关模型请求通过 `final_review.review_id` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。 页面在验收期间单独显示证据准备/核对/读取、结论整理、完整性校验阶段,以及已用时间、剩余总预算、请求与实际重试次数。诊断 JSON 的 `final_reviews` 记录每次验收的预算、截止时间和结束原因,相关模型请求通过 `final_review.review_id` 关联。请求超时仅重试同一模型请求,保留已取得的工具证据;耗尽请求重试或验收总预算时明确记为未验证,不再进入模型故障后的人工恢复等待。用户停止或探索指导引发的取消单独记录为中断,不重试。超时或中断后的半截输出会从流式展示中移除,只有完整响应经解析、校验后才可能确认通过。
+30
View File
@@ -0,0 +1,30 @@
# ARTEMIS 2.7.0
本版在正式 2.6.0 基础上新增 OpenAI Responses WebSocket 增量传输,减少多轮自动化任务重复上传完整历史的流量,并将 WebSocket 设为默认协议。
## WebSocket 增量传输
- Flash、Pro、探索及辅助模型的异步请求支持复用 Responses WebSocket 连接。首轮发送完整上下文;同一会话、角色、模型、凭据及路由范围内,后续输入严格延续上一轮完整历史时,仅发送响应 ID 和新增输入。图片、工具调用、结构化输出及用量统计保留原有解析与校验。
- 历史压缩、截图替换、生成选项或连接变化后重新发送当前完整上下文。服务端明确表示响应 ID 不存在且尚无输出时,最多恢复一次完整请求;流式输出中途断开或取消时,不在传输层盲目重发,不采纳半截工具参数或结论。
- 不同会话可并行,同一连接串行执行;连接池有数量、空闲和连接寿命限制。握手保持既有授权、路由、TLS 与代理策略,拒绝携带凭据跨地址重定向。
- 请求诊断增加实际应用载荷字节、增量输入、连接复用及重建原因。上传减少不等于模型上下文 token 减少,也不等于缓存命中;用量和缓存仍按服务端实报统计。不承诺固定流量节省比例。
## 默认协议与旧配置迁移
- 新安装默认使用 WebSocket。升级后首次启动,也会将旧用户已保存的全局 `OPENAI_TRANSPORT=http` 一次性改为 `websocket`,并写入内部迁移标记。保留接口地址、密钥及其它配置;失败时保留原文件并明确告警,下次启动重试。
- 迁移后可在“模型配置 → 传输协议”手动切回 HTTP,后续重启保留选择。网页、MCP 与环境配置共用此设置;JSON 模型或节点的显式协议覆盖继续有效。
- 网关必须支持 Responses WebSocket Upgrade、双向长连接及响应 ID 续接。仅透传会话请求头不代表支持;不支持时明确报错,可手动选择 HTTP,不会静默退回全量 HTTP 请求。
- 同步底层模型调用仍使用 HTTP。完整行为、配置方式与诊断字段见[传输说明](../ai-automation.md#responses-websocket-增量传输)。
## 版本与验证
- 主程序与前端根版本升级到 2.7.0;SDK、插件和 Android 助手保持现有独立版本。WebSocket 实现所需的 `websockets` 最低版本提高到 15,锁定的第三方包版本不变;此次升版只调整应用版本字段。
- 后端回归覆盖真实本地 WebSocket/SDK 多轮与工具调用、结构化输出、取消、重连、隔离、生命周期和诊断;配置迁移覆盖旧 HTTP、新安装、重复启动、手动切回与保存失败。
- 最终源码 ZIP 使用两个独立解压目录和空 npm 缓存,分别验证官方源、国内镜像的锁定安装及生产构建。实际运行时、源码提交、SHA256、测试结果和未测范围见发布附件 `INSTALL-VERIFICATION.md`。
- 此次不新增付费模型或设备业务任务,不替换 CUA 插件,也不接入 agent-desktop。此前网关联调记录不冒充本次发布的新增外部验证。
## 升级
已有安装等待任务结束后运行 `update.bat` 或 `update-cn.bat`,重启 ARTEMIS;Codex 用户重新加载 MCP。新安装下载 `artemis-2.7.0-source.zip`,对照 `SHA256SUMS.txt` 校验后解压启动,不覆盖正在运行的安装目录。
**升级会将旧的全局 HTTP 配置迁移为 WebSocket。** 网关暂不支持时,在升级后切回 HTTP;之后不会再自动重置。直接编辑 `.env` 时,保留 `ARTEMIS_TRANSPORT_DEFAULT_VERSION=1` 并设置 `OPENAI_TRANSPORT=http`,重启后生效。
+37
View File
@@ -0,0 +1,37 @@
# ARTEMIS 2.7.1
本版在正式 2.7.0 基础上改进 ADB 一致性、模型提取失败处理和故障证据留存,便于排查设备离线及批次中断。
## 任务操作与录屏共用 ADB
- scrcpy 录屏显式使用 ARTEMIS 选定的 ADB 可执行文件和服务端地址,覆盖普通录屏、分段及驱动直接录屏入口,减少各自选择不同 ADB 的情况。
- 运行环境诊断记录解析到的 ADB/scrcpy 路径、版本和 SHA256,以及可取得的本机 ADB 监听进程信息;远程服务端进程保留未知。
## 模型空结果与应用查找
- 结构化模型输出为空或解析失败时,进入现有的有限重试和兜底流程;Hopper 对“已找到但提取内容为空”的结果也执行校验,取消请求仍直接中断。
- 模型提取或初始化失败明确报错,与有效查询后未找到已安装应用分开;仅缓存已确认安装的包名,避免失败结果影响后续查找。
## 故障留档与批次恢复
- 新任务在开始和失败收尾时分别保存运行环境快照,包含程序/Python 版本、可取得的源码提交与本地修改标记、工具来源、宿主内存及任务进程内存。Windows 系统提交量、提交上限和余量按字节记录,不能当作页面文件单独大小。
- 单任务“全部资料”及批次 ZIP 各任务目录增加 `运行环境诊断.json`。升级前的批次 ZIP 缓存会重新打包;旧任务未采集的运行信息保留未知,导出时不使用当前环境补写历史。
- 服务重启后保留原暂停原因,恢复事件单独记录,批次仍需手动继续。诊断采集失败仅记日志,不改变任务结果。
- 快照仅在上述时点采集,不增加持续内存监控、保护阈值、自动暂停或录屏处理并发限制。它们用于减少干扰和保留证据,尚不能证明本次 ADB 崩溃的唯一原因,也不代表宿主内存压力已经解决。
详见[工作区诊断说明](../automation-workspace.md)。
## 版本与验证
- 主程序与前端根版本升级到 2.7.1;SDK、插件、Android 助手及锁定的第三方依赖保持现有独立版本。
- 回归覆盖录屏 ADB 环境隔离、模型空结果与解析错误重试、Hopper 兜底与取消、包名缓存、批次恢复、诊断导出和旧 ZIP 缓存升级。
- 最终源码 ZIP 使用独立解压目录和空 npm 缓存,分别验证官方源和国内镜像的锁定安装与生产构建。实际运行时、源码提交、SHA256、检查结果和未测范围见附件 `INSTALL-VERIFICATION.md`。
- 本次不恢复用户批次,不新增实机业务测试或付费模型调用;自动化回归不能代替现场内存压力与 ADB 崩溃复现。
## 升级
已有安装等待任务结束后运行 `update.bat` 或 `update-cn.bat`,再重启 ARTEMIS;Codex 用户重新加载 MCP。升级程序会终止本项目运行进程,请保留需要继续的暂停任务。
新安装下载 `artemis-2.7.1-source.zip`,对照 `SHA256SUMS.txt` 校验后解压启动,不覆盖正在运行的安装目录。
继续沿用 [2.7.0 的 WebSocket 默认协议及一次性配置迁移](2.7.0.md#默认协议与旧配置迁移)。已经手动切回 HTTP 的配置保持原选择。
+1 -1
View File
@@ -437,7 +437,7 @@
}, },
{ {
"name": "mobile_config", "name": "mobile_config",
"description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-terra), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n", "description": "Read configuration or explicitly save non-secret defaults for FUTURE tasks.\n\nOnly set persist_defaults=true when the user requests saved/global configuration.\nmodel changes: model (preset or custom), base_url, transport (http/websocket), reasoning_effort\n(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,\nretry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,\ntool_fallback_reasoning_effort, tool_fallback_max_retries,\ntool_fallback_no_progress_attempts. 'default' omits reasoning;\nunsupported values fail visibly.\ncapabilities: video_enabled and recording_enabled are independent; outputter_enabled,\nflash_final_check_enabled (false; Flash exit-only AI review),\nstep_summarizer_enabled, screenshot_limit_enabled, task_timeout_seconds (default 1800),\nobservation_timeout_seconds (60), observation_max_attempts (3),\ndevice_command_timeout_seconds (10), action_failure_attempts (3),\nworker_cleanup_timeout_seconds (30), adb_auto_connect (true),\nadb_discovery_interval_seconds (10), adb_discovery_timeout_seconds (5).\nreports: formats=['docx','xlsx'] (at least one).\nexploration: model (default gpt-6.1-terra), reasoning_effort (high), timeout_seconds\n(1800 active seconds), demonstration_timeout_seconds (900), demonstration_warning_seconds (60).\nAPI keys are never accepted or returned: use the Web UI or .env for credentials.\nSaving settings does not change an already running task. Flash/Pro are execution\nprofiles, not model names. Model retries do not repeat device test cases.\n",
"inputSchema": { "inputSchema": {
"properties": { "properties": {
"section": { "section": {
+2 -1
View File
@@ -14,6 +14,7 @@ from mcp_server.base import mcp
EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports" EXPORT_DIR = ROOT_DIR / "artifacts" / "mcp_exports"
MODEL_FIELDS = { MODEL_FIELDS = {
"transport",
"base_url", "base_url",
"model", "model",
"timeout_seconds", "timeout_seconds",
@@ -186,7 +187,7 @@ async def mobile_config(
"""Read configuration or explicitly save non-secret defaults for FUTURE tasks. """Read configuration or explicitly save non-secret defaults for FUTURE tasks.
Only set persist_defaults=true when the user requests saved/global configuration. Only set persist_defaults=true when the user requests saved/global configuration.
model changes: model (preset or custom), base_url, reasoning_effort model changes: model (preset or custom), base_url, transport (http/websocket), reasoning_effort
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries, (default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model, retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
tool_fallback_reasoning_effort, tool_fallback_max_retries, tool_fallback_reasoning_effort, tool_fallback_max_retries,
+2 -2
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.6.0" version = "2.7.1"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
@@ -57,7 +57,7 @@ dependencies = [
"uvicorn[standard]>=0.30.1", "uvicorn[standard]>=0.30.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"requests>=2.31.0", "requests>=2.31.0",
"websockets>=12.0", "websockets>=15.0",
"sseclient-py>=1.8.0", "sseclient-py>=1.8.0",
# Image, Video & Multimodal Perception # Image, Video & Multimodal Perception
"pillow>=13.0.0", "pillow>=13.0.0",
+2
View File
@@ -16,6 +16,8 @@
## 模式、模型与配置 ## 模式、模型与配置
网页模型配置和 `mobile_config(section="model")` 提供 `transport=http/websocket`,默认 WebSocket;首次升级启动也会将旧全局 HTTP 配置一次性迁移为 WebSocket,之后手动切回 HTTP 会被保留。用户明确要求保存默认值时可设置 `changes={"transport":"websocket"}, persist_defaults=true`;新任务的 Flash、Pro、探索和辅助请求共用此选择。WebSocket 在历史一致时仅上传新增输入,网关须支持 Responses WebSocket;压缩、重连会发送完整上下文,上传减少不能当作 token 减少或缓存命中。不支持协议时应查看错误并确认网关能力,不自动改写用户配置。
- `mobile_run_task.model` 取 `Flash` / `Pro`。API 模型名放 `runtime_overrides.model`;思考强度放 `runtime_overrides.reasoning_effort`。批次使用 `task_config.profile` 选择模式,`task_config.model` 选择 API 模型。 - `mobile_run_task.model` 取 `Flash` / `Pro`。API 模型名放 `runtime_overrides.model`;思考强度放 `runtime_overrides.reasoning_effort`。批次使用 `task_config.profile` 选择模式,`task_config.model` 选择 API 模型。
- 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。 - 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。
- 默认 high、单次模型请求时限 300 秒。`default` 表示不发送思考强度,`none` 是显式关闭,不能混为一谈;接口不支持参数时如实报告,不擅自降级。 - 默认 high、单次模型请求时限 300 秒。`default` 表示不发送思考强度,`none` 是显式关闭,不能混为一谈;接口不支持参数时如实报告,不擅自降级。
+70 -2
View File
@@ -181,18 +181,23 @@ def test_completed_batch_reuses_existing_archive(client, isolated, monkeypatch):
exporter.assert_not_called() exporter.assert_not_called()
def test_legacy_batch_archive_is_rebuilt_with_current_recording_layout(client, isolated): @pytest.mark.parametrize("signature", [None, "artemis-batch-layout-3"])
def test_legacy_batch_archive_is_rebuilt_with_runtime_diagnostics(client, isolated, signature):
batch = service.create_batch(payload()) batch = service.create_batch(payload())
batch_id = batch["id"] batch_id = batch["id"]
isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True}) isolated.put("batch", batch_id, {**batch, "status": "completed", "report_ready": True})
path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip" path = reports.EXPORT_ROOT / "batches" / batch_id / "批次报告.zip"
path.parent.mkdir(parents=True) path.parent.mkdir(parents=True)
path.write_bytes(b"legacy archive") path.write_bytes(b"legacy archive")
if signature:
reports.remember(path, signature)
base = f"/api/automation/batches/{batch_id}/archive" base = f"/api/automation/batches/{batch_id}/archive"
assert client.get(base + "/status").json()["status"] == "idle" assert client.get(base + "/status").json()["status"] == "idle"
response = client.get(base) response = client.get(base)
assert response.status_code == 200 assert response.status_code == 200
assert zipfile.is_zipfile(BytesIO(response.content)) assert zipfile.is_zipfile(BytesIO(response.content))
with zipfile.ZipFile(BytesIO(response.content)) as archive:
assert len([name for name in archive.namelist() if name.endswith("运行环境诊断.json")]) == 2
assert client.get(base + "/status").json()["status"] == "ready" assert client.get(base + "/status").json()["status"] == "ready"
@@ -721,7 +726,7 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"] assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
archive = reports.export_batch(batch["id"]) archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z: with zipfile.ZipFile(archive) as z:
assert len(z.namelist()) == 10 # summaries + reports, diagnostics and evidence per task assert len(z.namelist()) == 12 # summaries + reports, diagnostics and evidence per task
diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")] diagnostics = [name for name in z.namelist() if name.endswith("模型请求诊断.json")]
assert len(diagnostics) == 2 assert len(diagnostics) == 2
for name in diagnostics: for name in diagnostics:
@@ -1304,6 +1309,69 @@ def test_batch_zip_contains_each_tasks_android_artifacts(isolated):
assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"} assert {archive.read(name).decode() for name in names} == {"任务0", "任务1"}
@pytest.mark.parametrize("pause_source", ["device", "initialization", "user", "model"])
def test_restart_retains_original_pause_reason_and_archives_recovery(isolated, pause_source):
batch = service.create_batch(payload())
reason = "原始暂停原因"
isolated.put(
"batch",
batch["id"],
{**batch, "status": "paused", "pause_source": pause_source, "reason": reason},
)
service.recover_batches()
recovered = isolated.get("batch", batch["id"])
assert recovered["status"] == "paused"
assert recovered["pause_source"] == "recovery"
assert recovered["reason"] == reason
event = recovered["recovery_events"][0]
assert event["previous_reason"] == reason
assert event["previous_pause_source"] == pause_source
assert "服务已重启" in event["message"]
assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"]))
service.recover_batches()
assert isolated.get("batch", batch["id"])["reason"] == reason
def test_runtime_evidence_is_exported_from_the_task_without_resampling(isolated, monkeypatch):
batch = service.create_batch(payload())
first, second = isolated.jobs(batch["id"])
snapshot = {
"schema_version": 1,
"failure": {
"captured_at": 1,
"runtime": {"source_commit": "worker-commit"},
"memory": {"available_bytes": 163},
},
}
directory = reports.TRACES_PATH / first["session_id"]
directory.mkdir(parents=True)
(directory / "runtime_diagnostics.json").write_text(json.dumps(snapshot), encoding="utf-8")
isolated.put(
"batch",
batch["id"],
{**batch, "status": "paused", "reason": "设备离线", "pause_source": "device"},
)
service.recover_batches()
def assert_snapshots(archive_path):
with zipfile.ZipFile(archive_path) as archive:
records = [
json.loads(archive.read(name))
for name in archive.namelist()
if name.endswith("运行环境诊断.json")
]
recorded = next(record for record in records if record["runtime"])
assert recorded["runtime"] == snapshot
assert recorded["batch"]["reason"] == "设备离线"
assert recorded["batch"]["recovery_events"][0]["previous_pause_source"] == "device"
return records
assert_snapshots(reports.export_session_media(first["session_id"], "all"))
records = assert_snapshots(reports.export_batch(batch["id"]))
assert len(records) == 2
assert next(record for record in records if record["runtime"] is None)["runtime"] is None
def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files( def test_all_material_cache_reuses_reports_and_invalidates_on_review_or_new_files(
isolated, monkeypatch isolated, monkeypatch
): ):
@@ -9,6 +9,7 @@ from apps.admin_console.services import model_settings_service as service
from artemis.config.settings import Settings, settings from artemis.config.settings import Settings, settings
from artemis.config.agent import load_agent_config from artemis.config.agent import load_agent_config
from artemis.config.llm import parse_llm_config from artemis.config.llm import parse_llm_config
from artemis.config.environment import migrate_transport_default, TRANSPORT_DEFAULT_VERSION_KEY
@pytest.fixture @pytest.fixture
@@ -21,6 +22,7 @@ def isolated_settings(monkeypatch, tmp_path):
"OPENAI_MODEL", "OPENAI_MODEL",
"OPENAI_BASE_URL", "OPENAI_BASE_URL",
"OPENAI_TIMEOUT_SECONDS", "OPENAI_TIMEOUT_SECONDS",
"OPENAI_TRANSPORT",
"OPENAI_MAX_RETRIES", "OPENAI_MAX_RETRIES",
"OPENAI_RETRY_INTERVAL_SECONDS", "OPENAI_RETRY_INTERVAL_SECONDS",
"OPENAI_RATE_LIMIT_RETRY_SECONDS", "OPENAI_RATE_LIMIT_RETRY_SECONDS",
@@ -44,12 +46,14 @@ def isolated_settings(monkeypatch, tmp_path):
for key in keys: for key in keys:
monkeypatch.setattr(settings, key, getattr(settings, key)) monkeypatch.setattr(settings, key, getattr(settings, key))
monkeypatch.delenv(key, raising=False) monkeypatch.delenv(key, raising=False)
monkeypatch.delenv(TRANSPORT_DEFAULT_VERSION_KEY, raising=False)
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("existing-test-secret")) monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("existing-test-secret"))
return env return env
def test_save_reload_and_blank_key_preserves_secret(isolated_settings): def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
update = service.ModelSettingsUpdate( update = service.ModelSettingsUpdate(
transport="websocket",
base_url="https://gateway.example/v1/", base_url="https://gateway.example/v1/",
model="my-model", model="my-model",
api_key="new-test-secret", api_key="new-test-secret",
@@ -63,6 +67,9 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
) )
result = service.save_model_settings(update) result = service.save_model_settings(update)
assert result["api_key_configured"] assert result["api_key_configured"]
assert result["transport"] == "websocket"
assert dotenv_values(isolated_settings)["OPENAI_TRANSPORT"] == "websocket"
assert service.ModelSettingsUpdate(model="test").transport is None
assert "new-test-secret" not in repr(result) assert "new-test-secret" not in repr(result)
saved = dotenv_values(isolated_settings) saved = dotenv_values(isolated_settings)
assert saved["OPENAI_API_KEY"] == "new-test-secret" assert saved["OPENAI_API_KEY"] == "new-test-secret"
@@ -81,6 +88,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret" assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
def test_saved_http_opt_out_survives_restart_migration(isolated_settings):
result = service.save_model_settings(
service.ModelSettingsUpdate(model="test", transport="http")
)
assert result["transport"] == "http"
assert dotenv_values(isolated_settings)[TRANSPORT_DEFAULT_VERSION_KEY] == "1"
assert migrate_transport_default(isolated_settings) is False
assert Settings(_env_file=isolated_settings).OPENAI_TRANSPORT == "http"
def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch): def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
expected = "https://api-n-cd.playones.com/v1" expected = "https://api-n-cd.playones.com/v1"
@@ -226,9 +243,7 @@ def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings): def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
previous = settings.OPENAI_MODEL previous = settings.OPENAI_MODEL
monkeypatch.setattr( monkeypatch.setattr(os, "replace", lambda *args: (_ for _ in ()).throw(OSError("read only")))
service.os, "replace", lambda *args: (_ for _ in ()).throw(OSError("read only"))
)
with pytest.raises(OSError): with pytest.raises(OSError):
service.save_model_settings(service.ModelSettingsUpdate(model="not-saved")) service.save_model_settings(service.ModelSettingsUpdate(model="not-saved"))
assert settings.OPENAI_MODEL == previous assert settings.OPENAI_MODEL == previous
+64
View File
@@ -0,0 +1,64 @@
"""Hopper failures must not become successful negative lookups."""
import asyncio
from importlib import import_module
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from artemis.agents.hopper.hopper import HopperError, HopperOutput
module = import_module("artemis.agents.hopper.hopper")
@pytest.mark.asyncio
@pytest.mark.parametrize("invalid", [None, {}, {"found": True, "output": " ", "reason": "Empty"}])
async def test_invalid_extraction_switches_to_fallback(monkeypatch, invalid):
good = HopperOutput(found=True, output="com.example.app", reason="Matched")
primary = SimpleNamespace(ainvoke=AsyncMock(return_value=invalid))
secondary = SimpleNamespace(ainvoke=AsyncMock(return_value=good))
factory = Mock(
side_effect=[
SimpleNamespace(with_structured_output=lambda _: primary),
SimpleNamespace(with_structured_output=lambda _: secondary),
]
)
monkeypatch.setattr(module, "get_llm", factory)
assert (
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "com.example.app")
== good
)
assert primary.ainvoke.await_count == secondary.ainvoke.await_count == 1
@pytest.mark.asyncio
@pytest.mark.parametrize("use_fallback", [False, True])
async def test_exhausted_extraction_raises_a_distinct_error(monkeypatch, use_fallback):
model = SimpleNamespace(ainvoke=AsyncMock(return_value=None))
monkeypatch.setattr(
module, "get_llm", lambda **_: SimpleNamespace(with_structured_output=lambda _: model)
)
with pytest.raises(HopperError, match="模型未能完成数据提取"):
await module.hopper(
SimpleNamespace(data_engine=None), "Find app", "packages", use_fallback=use_fallback
)
assert model.ainvoke.await_count == (2 if use_fallback else 1)
@pytest.mark.asyncio
async def test_cancelled_extraction_never_uses_fallback(monkeypatch):
model = SimpleNamespace(ainvoke=AsyncMock(side_effect=asyncio.CancelledError))
monkeypatch.setattr(
module, "get_llm", lambda **_: SimpleNamespace(with_structured_output=lambda _: model)
)
with pytest.raises(asyncio.CancelledError):
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "packages")
assert model.ainvoke.await_count == 1
@pytest.mark.asyncio
async def test_model_setup_failure_is_not_reported_as_missing_data(monkeypatch):
monkeypatch.setattr(module, "get_llm", Mock(side_effect=ValueError("invalid model settings")))
with pytest.raises(HopperError, match="模型未能完成数据提取"):
await module.hopper(SimpleNamespace(data_engine=None), "Find app", "packages")
+117
View File
@@ -0,0 +1,117 @@
import json
import os
from pathlib import Path
import subprocess
import sys
from dotenv import dotenv_values
import pytest
from artemis.config.environment import (
migrate_transport_default,
persist_environment,
TRANSPORT_DEFAULT_VERSION_KEY,
)
@pytest.fixture(autouse=True)
def isolate_transport(monkeypatch):
monkeypatch.setenv("OPENAI_TRANSPORT", "http")
monkeypatch.delenv(TRANSPORT_DEFAULT_VERSION_KEY, raising=False)
@pytest.mark.parametrize("old", ["", "OPENAI_TRANSPORT=http\n", "OPENAI_TRANSPORT=websocket\n"])
def test_upgrade_migrates_legacy_preferences_once_and_preserves_config(tmp_path, old):
path = tmp_path / ".env"
preserved = (
"# 用户注释\nOPENAI_API_KEY='test-secret'\nOPENAI_BASE_URL=https://test.invalid/v1\n"
)
path.write_text(preserved + old, encoding="utf-8")
assert migrate_transport_default(path) is True
saved = dotenv_values(path)
assert saved["OPENAI_TRANSPORT"] == os.environ["OPENAI_TRANSPORT"] == "websocket"
assert saved[TRANSPORT_DEFAULT_VERSION_KEY] == "1"
assert path.read_text(encoding="utf-8").startswith(preserved)
previous = path.read_bytes()
assert migrate_transport_default(path) is False
assert path.read_bytes() == previous
persist_environment(path, {"OPENAI_TRANSPORT": "http"})
assert migrate_transport_default(path) is False
assert dotenv_values(path)["OPENAI_TRANSPORT"] == "http"
def test_failed_replace_preserves_original_and_can_retry(tmp_path, monkeypatch):
from artemis.config import environment
path = tmp_path / ".env"
original = "OPENAI_TRANSPORT=http\nUNRELATED=keep\n"
path.write_text(original, encoding="utf-8")
def fail(*_):
raise OSError("simulated disk error")
with monkeypatch.context() as patch:
patch.setattr(environment.os, "replace", fail)
with pytest.raises(OSError):
migrate_transport_default(path)
assert path.read_text(encoding="utf-8") == original
assert os.environ["OPENAI_TRANSPORT"] == "http"
assert not list(tmp_path.glob(".env-*"))
assert migrate_transport_default(path) is True
@pytest.mark.parametrize("version", [1, 2])
def test_completed_migration_preserves_http_without_write_access(tmp_path, monkeypatch, version):
from artemis.config import environment
path = tmp_path / ".env"
path.write_text(
f"OPENAI_TRANSPORT=http\n{TRANSPORT_DEFAULT_VERSION_KEY}={version}\n", encoding="utf-8"
)
def denied(*_, **__):
raise PermissionError("read-only deployment")
monkeypatch.setattr(environment, "FileLock", denied)
assert migrate_transport_default(path) is False
assert dotenv_values(path)["OPENAI_TRANSPORT"] == "http"
@pytest.mark.parametrize("new_install", [False, True])
def test_fresh_process_defaults_and_saved_opt_out(tmp_path, new_install):
path = tmp_path / ".env"
if not new_install:
path.write_text("OPENAI_TRANSPORT=http\n", encoding="utf-8")
env = {
k: v
for k, v in os.environ.items()
if k not in {"OPENAI_TRANSPORT", TRANSPORT_DEFAULT_VERSION_KEY}
}
env["ARTEMIS_APP_DIR"] = str(tmp_path)
env["PYTHONUTF8"] = "1"
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[3])
code = """
import json
from artemis.config.settings import settings
from artemis.config.llm import LLM, parse_llm_config
from artemis.llm.router import ModelEndpoint
print(json.dumps([settings.OPENAI_TRANSPORT, LLM().transport, ModelEndpoint().transport, parse_llm_config().operator.transport]))
"""
def start():
result = subprocess.run(
[sys.executable, "-c", code],
cwd=tmp_path,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
timeout=20,
check=True,
)
return json.loads(result.stdout.splitlines()[-1])
assert start() == ["websocket"] * 4
assert dotenv_values(path)[TRANSPORT_DEFAULT_VERSION_KEY] == "1"
persist_environment(path, {"OPENAI_TRANSPORT": "http"})
assert start() == ["http"] * 4
+2
View File
@@ -72,6 +72,8 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
payload = request.call_args.kwargs["json"] payload = request.call_args.kwargs["json"]
assert payload["rate_limit_retry_seconds"] == 90 assert payload["rate_limit_retry_seconds"] == 90
assert payload["tool_fallback_model"] == "gpt-6-luna" assert payload["tool_fallback_model"] == "gpt-6-luna"
await tools.mobile_config(changes={"transport": "websocket"}, persist_defaults=True)
assert request.call_args.kwargs["json"]["transport"] == "websocket"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -0,0 +1,144 @@
"""Runtime snapshots are bounded, read-only evidence, never a task gate."""
import ctypes
import hashlib
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import psutil
import pytest
from artemis.runtime import diagnostics as module
from artemis.runtime.adb_endpoint import AdbEndpoint
@pytest.fixture(autouse=True)
def isolated_identity_cache():
identity = module.runtime_identity
identity.cache_clear()
yield
identity.cache_clear()
def test_tool_identity_records_exact_binary_hash_and_version(monkeypatch, tmp_path):
adb = tmp_path / "platform tools" / "adb.exe"
adb.parent.mkdir()
adb.write_bytes(b"selected adb")
monkeypatch.setattr(module.toolchain, "resolve", lambda _: str(adb))
command = Mock(return_value="Android Debug Bridge version test")
monkeypatch.setattr(module, "_command", command)
record = module._tool_identity("adb")
assert record["path"] == str(adb)
assert record["sha256"] == hashlib.sha256(b"selected adb").hexdigest()
assert record["version"] == command.return_value
command.assert_called_once_with([str(adb), "version"])
def test_unknown_source_and_archive_identity_are_not_invented(monkeypatch, tmp_path):
monkeypatch.setattr(module, "ROOT_DIR", tmp_path)
monkeypatch.setattr(module, "get_version", lambda: "test-version")
command = Mock()
monkeypatch.setattr(module, "_command", command)
monkeypatch.setenv("OPENAI_API_KEY", "private-test-key")
record = module.runtime_identity(False)
assert record["source_commit"] is None
assert record["source_modified"] is None
assert record["installation_archive_sha256"] is None
assert record["artemis_version"] == "test-version"
assert record["tools"] == {}
assert "private-test-key" not in json.dumps(record)
command.assert_not_called()
def test_version_commands_are_bounded_and_never_repair_devices(monkeypatch):
run = Mock(return_value=SimpleNamespace(stdout="version", returncode=0))
monkeypatch.setattr(module.subprocess, "run", run)
assert module._command(["adb", "version"]) == "version"
assert run.call_args.kwargs["timeout"] == 3
assert run.call_args.kwargs["check"] is False
assert run.call_args.args == (["adb", "version"],)
run.side_effect = OSError("Cannot create process")
assert module._command(["adb", "version"]) is None
@pytest.mark.skipif(module.sys.platform != "win32", reason="Windows PSAPI structure")
def test_system_commit_converts_pages_to_bytes(monkeypatch):
def query(pointer, size):
values = pointer._obj
assert size == ctypes.sizeof(module._PerformanceInformation)
values.PageSize = 4096
values.CommitTotal = 900
values.CommitLimit = 1000
return True
dll = SimpleNamespace(GetPerformanceInfo=Mock(side_effect=query))
monkeypatch.setattr(module.ctypes, "WinDLL", lambda *args, **kwargs: dll)
result = module._windows_commit()
assert result["committed_bytes"] == 900 * 4096
assert result["limit_bytes"] == 1000 * 4096
assert result["available_bytes"] == 100 * 4096
def test_remote_adb_process_is_explicitly_unknown(monkeypatch):
monkeypatch.setattr(
module, "current_adb_endpoint", lambda: AdbEndpoint.create("192.0.2.10", 5037)
)
connections = Mock()
monkeypatch.setattr(module.psutil, "net_connections", connections)
result = module._adb_server()
assert result["local_listener"] is None
assert result["endpoint"]["host"] == "192.0.2.10"
connections.assert_not_called()
def test_local_adb_listener_identity_excludes_unrelated_bindings(monkeypatch):
monkeypatch.setattr(module, "current_adb_endpoint", lambda: AdbEndpoint.local())
connections = [
SimpleNamespace(status=psutil.CONN_LISTEN, laddr=SimpleNamespace(ip=ip, port=5037), pid=pid)
for ip, pid in [("192.0.2.10", 5), ("127.0.0.1", 6)]
]
monkeypatch.setattr(module.psutil, "net_connections", lambda **_: connections)
process = SimpleNamespace(pid=6, name=lambda: "adb.exe", exe=lambda: "selected/adb.exe")
lookup = Mock(return_value=process)
monkeypatch.setattr(module.psutil, "Process", lookup)
assert module._adb_server()["local_listener"] == {
"pid": 6,
"name": "adb.exe",
"path": "selected/adb.exe",
}
lookup.assert_called_once_with(6)
def test_start_snapshot_survives_failure_and_repeated_start(monkeypatch, tmp_path):
identity = Mock(return_value={"source_commit": "test-commit"})
memory = Mock(side_effect=[{"available_bytes": 100}, {"available_bytes": 1}])
monkeypatch.setattr(module, "runtime_identity", identity)
monkeypatch.setattr(module, "_memory_snapshot", memory)
monkeypatch.setattr(module, "_adb_server", lambda: {"local_listener": None})
module.write_runtime_diagnostics(tmp_path, "start")
module.write_runtime_diagnostics(tmp_path, "start")
module.write_runtime_diagnostics(tmp_path, "failure")
payload = json.loads((tmp_path / module.FILENAME).read_text(encoding="utf-8"))
assert payload["start"]["memory"]["available_bytes"] == 100
assert payload["failure"]["memory"]["available_bytes"] == 1
assert payload["failure"]["runtime"]["source_commit"] == "test-commit"
assert memory.call_count == 2
assert not (tmp_path / "runtime_diagnostics.tmp").exists()
def test_snapshot_failure_cannot_fail_a_task(monkeypatch, tmp_path):
monkeypatch.setattr(module, "runtime_identity", lambda _: {})
monkeypatch.setattr(module, "_memory_snapshot", Mock(side_effect=OSError("commit exhausted")))
module.write_runtime_diagnostics(tmp_path, "failure")
assert not (tmp_path / module.FILENAME).exists()
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error", [OSError("commit exhausted"), RuntimeError("cannot start thread")]
)
async def test_snapshot_thread_creation_failure_is_optional(monkeypatch, tmp_path, error):
monkeypatch.setattr(module.asyncio, "to_thread", AsyncMock(side_effect=error))
await module.capture_runtime_diagnostics(tmp_path, "start")
assert not (tmp_path / module.FILENAME).exists()
@@ -22,6 +22,22 @@ from artemis.runtime.device_lock import DeviceBusyError
from artemis.sdk.types.exceptions import AgentError from artemis.sdk.types.exceptions import AgentError
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["failed", "completed", "cancelled"])
async def test_failure_snapshot_is_written_before_tracing_ends(tmp_path, status):
agent = object.__new__(Agent)
task = MagicMock(status=status)
context = MagicMock(execution_target="computer", execution_setup=None)
context.data_engine.base_dir = tmp_path
with patch("artemis.runtime.diagnostics.write_runtime_diagnostics") as snapshot:
await agent._finalize_tracing(task, context)
if status == "failed":
snapshot.assert_called_once_with(tmp_path, "failure", android=False)
else:
snapshot.assert_not_called()
context.data_engine.end_session.assert_called_once_with(status=status)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_trace_finalization_failure_does_not_escape(): async def test_trace_finalization_failure_does_not_escape():
agent = object.__new__(Agent) agent = object.__new__(Agent)
+50
View File
@@ -1,6 +1,7 @@
"""Tests for the LLM gateway: complete(), classified recovery, and fallback.""" """Tests for the LLM gateway: complete(), classified recovery, and fallback."""
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
import pytest import pytest
@@ -16,6 +17,55 @@ from artemis.services.llm import (
) )
@pytest.mark.asyncio
@pytest.mark.parametrize(
"invalid",
[
None,
{"raw": AIMessage(content=""), "parsed": None, "parsing_error": None},
{
"raw": AIMessage(content=""),
"parsed": None,
"parsing_error": ValueError("Invalid schema"),
},
],
)
async def test_missing_structured_output_retries_inside_existing_budget(invalid):
expected = {"found": True, "output": "com.example.app", "reason": "Matched"}
base = SimpleNamespace(
ainvoke=AsyncMock(
side_effect=[
invalid,
{"raw": AIMessage(content=""), "parsed": expected, "parsing_error": None},
]
)
)
model = RobustChatModelWrapper(base, structured_mode="parsed")
assert await model.ainvoke([]) == expected
assert base.ainvoke.await_count == 2
@pytest.mark.asyncio
async def test_empty_structured_output_exhausts_then_uses_fallback():
primary = SimpleNamespace(
ainvoke=AsyncMock(return_value={"raw": AIMessage(content=""), "parsed": None})
)
expected = {"found": False, "output": None, "reason": "No match"}
secondary = SimpleNamespace(
ainvoke=AsyncMock(return_value={"raw": AIMessage(content=""), "parsed": expected})
)
main = RobustChatModelWrapper(
primary, endpoint=ModelEndpoint(model_name="primary"), structured_mode="parsed"
)
fallback = RobustChatModelWrapper(
secondary, endpoint=ModelEndpoint(model_name="fallback"), structured_mode="parsed"
)
result = await with_fallback(lambda: main.ainvoke([]), lambda: fallback.ainvoke([]))
assert result == expected
assert primary.ainvoke.await_count == 2
assert secondary.ainvoke.await_count == 1
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolate_gateway_state(monkeypatch, tmp_path): def _isolate_gateway_state(monkeypatch, tmp_path):
llm_service._ENDPOINT_BREAKER._states.clear() llm_service._ENDPOINT_BREAKER._states.clear()
+1 -1
View File
@@ -123,7 +123,7 @@ async def transport_case(monkeypatch, tmp_path, request):
) )
llm_service._ENDPOINT_BREAKER._states.clear() llm_service._ENDPOINT_BREAKER._states.clear()
llm_service._NON_STREAMING_ENDPOINTS.clear() llm_service._NON_STREAMING_ENDPOINTS.clear()
endpoint = ModelEndpoint(model_name="test-model", max_retries=0) endpoint = ModelEndpoint(model_name="test-model", transport="http", max_retries=0)
model = ModelFactory.create_model(endpoint) model = ModelFactory.create_model(endpoint)
assert isinstance(model, ChatOpenAI) assert isinstance(model, ChatOpenAI)
yield SimpleNamespace( yield SimpleNamespace(
+1 -1
View File
@@ -17,7 +17,7 @@ async def test_sync_and_async_clients_follow_env_only_proxy_policy(monkeypatch,
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False) monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", use_proxy) monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", use_proxy)
model = ModelFactory.create_model( model = ModelFactory.create_model(
ModelEndpoint(api_key="test-only", api_base="https://example.invalid/v1") ModelEndpoint(api_key="test-only", api_base="https://example.invalid/v1", transport="http")
) )
try: try:
for client in (model.http_client, model.http_async_client): for client in (model.http_client, model.http_async_client):
+3
View File
@@ -43,6 +43,9 @@ def transport_model(monkeypatch):
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
import openai import openai
# These fixtures exercise the explicitly selected HTTP compatibility path.
monkeypatch.setattr(settings, "OPENAI_TRANSPORT", "http")
requests = [] requests = []
outputs = [] outputs = []
+607
View File
@@ -0,0 +1,607 @@
"""Real local WebSocket/SDK round trips; no device, credential, or paid endpoint."""
import asyncio
from contextlib import asynccontextmanager
from copy import deepcopy
import json
import httpx
from langchain_core.messages import HumanMessage, ToolMessage
import pytest
import pytest_asyncio
from websockets.asyncio.server import serve
from artemis.llm.diagnostics import ACTIVE_ATTEMPT
from artemis.llm.response_guard import ResponseIntegrityError, ensure_response_complete
from artemis.llm.router import ModelEndpoint, ModelFactory
from artemis.services.llm import RobustChatModelWrapper
def text_output(text="OK"):
return [
{
"id": "msg_test",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": text, "annotations": [], "logprobs": []}],
}
]
class Wire:
def __init__(self):
self.requests = []
self.connections = []
self.outputs = []
self.reject_previous = False
self.drop = False
self.stall = False
self.status = "completed"
self.sent = asyncio.Event()
self.gate = None
async def handle(self, socket):
self.connections.append(socket)
async for raw in socket:
body = json.loads(raw)
self.requests.append(body)
self.sent.set()
if self.reject_previous and body.get("previous_response_id"):
await socket.send(
json.dumps(
{
"type": "error",
"status": 400,
"error": {"code": "previous_response_not_found"},
}
)
)
continue
response = {
"id": f"resp_{len(self.requests)}",
"object": "response",
"created_at": 1,
"status": "in_progress",
"model": "test-model",
"output": [],
}
await socket.send(
json.dumps({"type": "response.created", "sequence_number": 0, "response": response})
)
if self.drop:
await socket.close()
continue
if self.stall:
await socket.wait_closed()
return
if self.gate:
await self.gate.wait()
output = self.outputs.pop(0) if self.outputs else text_output()
seq = 0
for index, item in enumerate(output):
seq += 1
await socket.send(
json.dumps(
{
"type": "response.output_item.added",
"sequence_number": seq,
"output_index": index,
"item": {**item, "content": [], "arguments": ""},
}
)
)
for content_index, block in enumerate(item.get("content", [])):
if block.get("type") != "output_text":
continue
seq += 1
await socket.send(
json.dumps(
{
"type": "response.output_text.delta",
"sequence_number": seq,
"output_index": index,
"content_index": content_index,
"item_id": item["id"],
"delta": block["text"],
}
)
)
seq += 1
await socket.send(
json.dumps(
{
"type": "response.output_text.done",
"sequence_number": seq,
"output_index": index,
"content_index": content_index,
"item_id": item["id"],
"text": block["text"],
}
)
)
seq += 1
await socket.send(
json.dumps(
{
"type": "response.output_item.done",
"sequence_number": seq,
"output_index": index,
"item": item,
}
)
)
response.update(
status=self.status,
output=output,
usage={
"input_tokens": 20,
"output_tokens": 3,
"total_tokens": 23,
"input_tokens_details": {"cached_tokens": 10},
},
)
await socket.send(
json.dumps(
{
"type": f"response.{self.status}",
"sequence_number": seq + 1,
"response": response,
}
)
)
@pytest_asyncio.fixture
async def wire(monkeypatch):
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
instance = Wire()
async with serve(instance.handle, "127.0.0.1", 0) as server:
instance.base = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
yield instance
@asynccontextmanager
async def model(wire, **kwargs):
endpoint = ModelEndpoint(
model_name="test-model",
api_key="test-secret",
api_base=wire.base,
transport="websocket",
max_retries=0,
timeout_seconds=2,
**kwargs,
)
value = ModelFactory.create_model(endpoint)
try:
yield value
finally:
await value.http_async_client.aclose()
value.http_client.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("stream", [False, True])
async def test_sdk_roundtrip_incremental_history_and_usage(wire, stream):
async with model(wire) as raw:
messages = [
HumanMessage(
content=[
{"type": "text", "text": "Inspect"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + "A" * 80000},
},
]
)
]
async def call(history):
if not stream:
return await raw.ainvoke(history, prompt_cache_key="task:operator")
chunks = [
chunk async for chunk in raw.astream(history, prompt_cache_key="task:operator")
]
return sum(chunks[1:], chunks[0])
first = await call(messages)
ensure_response_complete(first)
second = await call([*messages, first, HumanMessage(content="Continue")])
ensure_response_complete(second)
assert second.usage_metadata["input_token_details"]["cache_read"] == 10
assert wire.requests[1].get("previous_response_id") == "resp_1", {
"first": first.content,
"tail": wire.requests[1]["input"][1:],
}
assert wire.requests[1]["input"] == [
{"type": "message", "role": "user", "content": "Continue"}
]
assert "stream" not in wire.requests[0]
assert wire.requests[0]["type"] == "response.create"
assert wire.requests[0]["store"] is False
assert len(json.dumps(wire.requests[1])) < len(json.dumps(wire.requests[0])) / 100
assert len(wire.connections) == 1
assert wire.connections[0].request.path == "/v1/responses"
assert wire.connections[0].request.headers["Authorization"] == "Bearer test-secret"
@pytest.mark.asyncio
async def test_tool_call_chain_preserves_call_id(wire):
wire.outputs.append(
[
{
"id": "fc_test",
"type": "function_call",
"call_id": "call_test",
"name": "lookup",
"arguments": '{"x":1}',
"status": "completed",
}
]
)
async with model(wire) as raw:
messages = [HumanMessage(content="Look up")]
first = await raw.ainvoke(messages, prompt_cache_key="task:operator")
await raw.ainvoke(
[*messages, first, ToolMessage(content="found", tool_call_id="call_test")],
prompt_cache_key="task:operator",
)
assert wire.requests[1]["previous_response_id"] == "resp_1"
assert wire.requests[1]["input"] == [
{"type": "function_call_output", "call_id": "call_test", "output": "found"}
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"change", ["history", "options", "scope", "new_connection", "server_closed"]
)
async def test_chain_resets_on_context_or_connection_change(wire, change):
async with model(wire) as raw:
messages = [HumanMessage(content="Original")]
first = await raw.ainvoke(messages, prompt_cache_key="task:operator")
history = [*messages, first, HumanMessage(content="Next")]
options = {"prompt_cache_key": "task:operator"}
if change == "history":
history[0] = HumanMessage(content="Compacted")
if change == "options":
options["temperature"] = 0.3
if change == "scope":
options["prompt_cache_key"] = "task:checker"
if change == "new_connection":
await raw.http_async_client._transport.sessions[
next(iter(raw.http_async_client._transport.sessions))
].close()
if change == "server_closed":
await wire.connections[0].close()
await wire.connections[0].wait_closed()
await raw.ainvoke(history, **options)
assert "previous_response_id" not in wire.requests[1]
assert len(wire.requests[1]["input"]) == 3
@pytest.mark.asyncio
async def test_uncached_id_recovers_once_with_full_context(wire):
async with model(wire) as raw:
history = [HumanMessage(content="First")]
first = await raw.ainvoke(history, prompt_cache_key="task:operator")
wire.reject_previous = True
result = await raw.ainvoke(
[*history, first, HumanMessage(content="Next")], prompt_cache_key="task:operator"
)
ensure_response_complete(result)
assert len(wire.requests) == 3
assert wire.requests[1]["previous_response_id"] == "resp_1"
assert "previous_response_id" not in wire.requests[2]
assert len(wire.requests[2]["input"]) == 3
@pytest.mark.asyncio
async def test_missing_affinity_never_shares_connections(wire):
async with model(wire) as raw:
history = [HumanMessage(content="First")]
first = await raw.ainvoke(history)
await raw.ainvoke([*history, first, HumanMessage(content="Next")])
assert len(wire.connections) == 2
assert "previous_response_id" not in wire.requests[1]
@pytest.mark.asyncio
async def test_cancel_closes_socket_and_next_call_starts_full(wire):
async with model(wire) as raw:
wire.stall = True
task = asyncio.create_task(
raw.ainvoke([HumanMessage(content="Wait")], prompt_cache_key="task:operator")
)
await asyncio.wait_for(wire.sent.wait(), 2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.wait_for(wire.connections[0].wait_closed(), 2)
wire.stall = False
await raw.ainvoke([HumanMessage(content="Next")], prompt_cache_key="task:operator")
assert len(wire.connections) == 2
assert "previous_response_id" not in wire.requests[1]
@pytest.mark.asyncio
async def test_disconnect_never_silently_resends_model_request(wire):
async with model(wire) as raw:
wire.drop = True
with pytest.raises(Exception, match="connection|Connection"):
await raw.ainvoke([HumanMessage(content="Test")], prompt_cache_key="task:operator")
assert len(wire.requests) == 1
@pytest.mark.asyncio
async def test_incomplete_result_does_not_advance_chain(wire):
async with model(wire) as raw:
wire.status = "incomplete"
first = await raw.ainvoke([HumanMessage(content="First")], prompt_cache_key="task:operator")
with pytest.raises(ResponseIntegrityError):
ensure_response_complete(first)
wire.status = "completed"
await raw.ainvoke(
[HumanMessage(content="First"), first, HumanMessage(content="Next")],
prompt_cache_key="task:operator",
)
assert "previous_response_id" not in wire.requests[1]
@pytest.mark.asyncio
async def test_diagnostics_measure_actual_wire_payload_without_logging_content(wire):
class Attempt:
data = {}
def publish_if_due(self):
pass
attempt = Attempt()
async with model(wire) as raw:
# Disable the unrelated HTTP trace hook: this checks the transport's
# own byte counters, alongside the real SDK request serialization.
raw.http_async_client.event_hooks = {"request": [], "response": []}
first = await raw.ainvoke(
[HumanMessage(content="A" * 5000)], prompt_cache_key="task:operator"
)
token = ACTIVE_ATTEMPT.set(attempt)
try:
await raw.ainvoke(
[HumanMessage(content="A" * 5000), first, HumanMessage(content="Next")],
prompt_cache_key="task:operator",
)
finally:
ACTIVE_ATTEMPT.reset(token)
stats = deepcopy(attempt.data["transport"])
assert stats["incremental"] is True
assert stats["sent_payload_bytes"] == len(
json.dumps(
wire.requests[1], ensure_ascii=False, separators=(",", ":"), sort_keys=True
).encode()
)
assert stats["payload_bytes_saved"] > 4000
assert "AAAA" not in json.dumps(stats)
@pytest.mark.asyncio
async def test_gateway_rejects_upgrade_with_explicit_error(wire):
import openai
from websockets.http11 import Response
from websockets.datastructures import Headers
async with serve(
wire.handle,
"127.0.0.1",
0,
process_request=lambda *_: Response(404, "Not Found", Headers(), b""),
) as server:
wire.base = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
async with model(wire) as raw:
with pytest.raises(openai.NotFoundError, match="WebSocket"):
await raw.ainvoke([HumanMessage(content="Test")])
@pytest.mark.asyncio
async def test_wrapper_retains_stream_guards_and_diagnostic_events(wire, monkeypatch):
from artemis.services import llm
from tests.unit.test_responses_wire_lifecycle import Engine
engine = Engine()
monkeypatch.setattr(llm, "_get_current_data_engine", lambda: engine)
async with model(wire) as raw:
wrapper = RobustChatModelWrapper(
raw,
endpoint=ModelEndpoint(model_name="test-model", transport="websocket", max_retries=0),
)
result = await wrapper.ainvoke(
[HumanMessage(content="Test")], prompt_cache_key="task:operator"
)
ensure_response_complete(result)
assert engine.data["transport"]["mode"] == "websocket"
assert engine.data["transport"]["received_payload_bytes"] > 0
assert engine.data.get("http_status") is None
@pytest.mark.asyncio
async def test_parallel_scopes_keep_independent_response_chains(wire):
async with model(wire) as raw:
async def conversation(scope):
history = [HumanMessage(content=f"First {scope}")]
first = await raw.ainvoke(history, prompt_cache_key=scope)
await raw.ainvoke(
[*history, first, HumanMessage(content=f"Next {scope}")], prompt_cache_key=scope
)
return scope, first.response_metadata["id"]
results = await asyncio.gather(
conversation("task-a:operator"), conversation("task-b:operator")
)
assert len(wire.connections) == 2
for scope, response_id in results:
calls = [item for item in wire.requests if item["prompt_cache_key"] == scope]
assert len(calls) == 2
assert calls[1]["previous_response_id"] == response_id
assert len(calls[1]["input"]) == 1
@pytest.mark.asyncio
async def test_idle_connections_are_closed_and_removed(wire, monkeypatch):
from artemis.llm import websocket
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.01)
async with model(wire) as raw:
await raw.ainvoke([HumanMessage(content="Test")], prompt_cache_key="task:operator")
await asyncio.wait_for(wire.connections[0].wait_closed(), 2)
assert not raw.http_async_client._transport.sessions
@pytest.mark.asyncio
async def test_cancel_waiting_turn_keeps_active_chain_and_idle_cleanup(wire, monkeypatch):
from artemis.llm import websocket
# Only shorten the final idle interval; scheduler latency must not break the first chain.
monkeypatch.setattr(websocket, "IDLE_SECONDS", 60)
wire.gate = asyncio.Event()
async with model(wire) as raw:
history = [HumanMessage(content="First")]
first = asyncio.create_task(raw.ainvoke(history, prompt_cache_key="task:operator"))
await asyncio.wait_for(wire.sent.wait(), 2)
queued = asyncio.create_task(raw.ainvoke(history, prompt_cache_key="task:operator"))
async with asyncio.timeout(2):
while next(iter(raw.http_async_client._transport.sessions.values())).users != 2:
await asyncio.sleep(0)
queued.cancel()
with pytest.raises(asyncio.CancelledError):
await queued
wire.gate.set()
result = await first
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.01)
await raw.ainvoke(
[*history, result, HumanMessage(content="Next")], prompt_cache_key="task:operator"
)
assert len(wire.connections) == 1
assert len(wire.requests) == 2
assert wire.requests[1]["previous_response_id"] == result.response_metadata["id"]
await asyncio.wait_for(wire.connections[0].wait_closed(), 2)
assert not raw.http_async_client._transport.sessions
@pytest.mark.asyncio
async def test_pool_eviction_does_not_split_concurrent_scope(wire, monkeypatch):
from artemis.llm import websocket
monkeypatch.setattr(websocket, "MAX_SESSIONS", 1)
closing, resume = asyncio.Event(), asyncio.Event()
async with model(wire) as raw:
await raw.ainvoke([HumanMessage(content="Old")], prompt_cache_key="old")
old = next(iter(raw.http_async_client._transport.sessions.values()))
close = old.close
async def delayed_close():
closing.set()
await resume.wait()
await close()
monkeypatch.setattr(old, "close", delayed_close)
first = asyncio.create_task(
raw.ainvoke([HumanMessage(content="New first")], prompt_cache_key="new")
)
try:
await asyncio.wait_for(closing.wait(), 2)
await asyncio.wait_for(
asyncio.gather(
first,
raw.ainvoke([HumanMessage(content="New second")], prompt_cache_key="new"),
),
2,
)
assert len(wire.connections) == 2
assert len(wire.requests) == 3
finally:
resume.set()
@pytest.mark.asyncio
async def test_structured_output_uses_same_websocket_path(wire):
from pydantic import BaseModel
class Result(BaseModel):
ok: bool
wire.outputs.append(
[
{
"id": "fc_test",
"type": "function_call",
"call_id": "call_test",
"name": "Result",
"arguments": '{"ok":true}',
"status": "completed",
}
]
)
async with model(wire) as raw:
result = (
await RobustChatModelWrapper(
raw, endpoint=ModelEndpoint(transport="websocket", max_retries=0)
)
.with_structured_output(Result)
.ainvoke([HumanMessage(content="Test")])
)
assert result.ok is True
assert len(wire.requests) == 1
@pytest.mark.asyncio
async def test_invalid_tool_arguments_are_rejected_before_use(wire, monkeypatch):
from artemis.services import llm
from artemis.llm.reliability import LLMExhaustedError
monkeypatch.setattr(llm, "_get_current_data_engine", lambda: None)
wire.outputs.append(
[
{
"id": "fc_test",
"type": "function_call",
"call_id": "call_test",
"name": "lookup",
"arguments": '{"x":',
"status": "completed",
}
]
)
async with model(wire) as raw:
wrapper = RobustChatModelWrapper(
raw, endpoint=ModelEndpoint(transport="websocket", max_retries=0)
)
with pytest.raises(LLMExhaustedError):
await wrapper.ainvoke([HumanMessage(content="Test")], prompt_cache_key="task:operator")
assert len(wire.requests) == 1
@pytest.mark.asyncio
async def test_redirect_does_not_forward_authorization(wire):
from websockets.http11 import Response
from websockets.datastructures import Headers
async with serve(
wire.handle,
"127.0.0.1",
0,
process_request=lambda *_: Response(
307,
"Redirect",
Headers({"Location": wire.base.replace("http://", "ws://") + "/responses"}),
b"",
),
) as server:
redirect_base = f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}/v1"
original_base, wire.base = wire.base, redirect_base
try:
async with model(wire) as raw:
with pytest.raises(Exception, match="redirects are disabled"):
await raw.ainvoke([HumanMessage(content="Test")])
assert not wire.connections
finally:
wire.base = original_base
+4 -1
View File
@@ -124,7 +124,10 @@ async def case(monkeypatch, tmp_path):
def wrapper(timeout=30, max_retries=0): def wrapper(timeout=30, max_retries=0):
endpoint = ModelEndpoint( endpoint = ModelEndpoint(
model_name="test-model", timeout_seconds=timeout, max_retries=max_retries model_name="test-model",
transport="http",
timeout_seconds=timeout,
max_retries=max_retries,
) )
model = ModelFactory.create_model(endpoint) model = ModelFactory.create_model(endpoint)
assert isinstance(model, ChatOpenAI) assert isinstance(model, ChatOpenAI)
@@ -25,6 +25,7 @@ import pytest
import cv2 import cv2
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.config.settings import settings
from artemis.controllers.unified_controller import UnifiedMobileController from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.drivers.mock.mock_driver import MockDeviceDriver from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.utils.video import ( from artemis.utils.video import (
@@ -42,6 +43,55 @@ from artemis.utils.video import (
) )
@pytest.mark.asyncio
@pytest.mark.parametrize("host,port", [("127.0.0.1", 5037), ("192.0.2.10", 5038)])
async def test_scrcpy_uses_selected_adb_binary_and_endpoint(monkeypatch, tmp_path, host, port):
import os
adb = str(tmp_path / "platform tools" / "adb.exe")
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: adb)
monkeypatch.setattr(settings, "ADB_HOST", host)
monkeypatch.setattr(settings, "ADB_PORT", port)
monkeypatch.setenv("ADB", "other-adb")
monkeypatch.setenv("ADB_SERVER_SOCKET", "tcp:unrelated:5999")
command = ["scrcpy", "--no-playback"]
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
await UnifiedMobileController._spawn_scrcpy(command)
environment = spawn.call_args.kwargs["env"]
assert environment["ADB"] == adb
assert environment["ADB_SERVER_SOCKET"] == f"tcp:{'localhost' if port == 5037 else host}:{port}"
assert environment["ADB_HOST"] == host
assert environment["ADB_PORT"] == str(port)
assert spawn.call_args.args == tuple(command)
assert os.environ["ADB"] == "other-adb"
assert os.environ["ADB_SERVER_SOCKET"] == "tcp:unrelated:5999"
@pytest.mark.asyncio
async def test_scrcpy_does_not_fall_back_to_an_unselected_adb(monkeypatch):
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: None)
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
with pytest.raises(FileNotFoundError, match="adb"):
await UnifiedMobileController._spawn_scrcpy(["scrcpy"])
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_android_recording_also_uses_selected_adb(monkeypatch, tmp_path):
from artemis.drivers.android.adb_driver import AndroidAdbDriver
adb = str(tmp_path / "platform-tools" / "adb.exe")
monkeypatch.setattr("artemis.runtime.adb_endpoint.toolchain.resolve", lambda _: adb)
monkeypatch.setattr(settings, "ADB_HOST", "192.0.2.10")
monkeypatch.setattr(settings, "ADB_PORT", 5038)
driver = AndroidAdbDriver("fixture-device", MagicMock())
with patch("asyncio.create_subprocess_exec", AsyncMock()) as spawn:
await driver.start_video_recording(tmp_path)
assert spawn.await_count == 1
assert spawn.call_args.kwargs["env"]["ADB"] == adb
assert spawn.call_args.kwargs["env"]["ADB_SERVER_SOCKET"] == "tcp:192.0.2.10:5038"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path): async def test_audio_extraction_rejects_silent_video_without_running_ffmpeg(tmp_path):
silent_video = tmp_path / "silent.mp4" silent_video = tmp_path / "silent.mp4"
+37 -7
View File
@@ -13,9 +13,9 @@
# limitations under the License. # limitations under the License.
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from artemis.agents.hopper.hopper import HopperOutput from artemis.agents.hopper.hopper import HopperError, HopperOutput
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.tools.mobile.launch_app import find_package from artemis.tools.mobile.launch_app import find_package, launch_app
import pytest import pytest
@@ -55,7 +55,7 @@ async def test_find_package_cache_hit(mock_hopper, mock_list_packages, mock_cont
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async") @patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.hopper") @patch("artemis.tools.mobile.launch_app.hopper")
async def test_find_package_not_found_caches_none(mock_hopper, mock_list_packages, mock_context): async def test_find_package_not_found_is_not_cached(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1\ncom.example.app2" mock_list_packages.return_value = "com.example.app1\ncom.example.app2"
# Hopper doesn't find it # Hopper doesn't find it
@@ -64,13 +64,14 @@ async def test_find_package_not_found_caches_none(mock_hopper, mock_list_package
pkg1 = await find_package(mock_context, "App 3") pkg1 = await find_package(mock_context, "App 3")
assert pkg1 is None assert pkg1 is None
assert mock_hopper.call_count == 1 assert mock_hopper.call_count == 1
assert mock_context.package_cache["App 3"] is None assert "App 3" not in mock_context.package_cache
# Second call: cache hit (None), does not call hopper again # An earlier miss must not prevent a later successful lookup.
mock_hopper.reset_mock() mock_hopper.reset_mock()
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
pkg2 = await find_package(mock_context, "App 3") pkg2 = await find_package(mock_context, "App 3")
assert pkg2 is None assert pkg2 == "com.example.app1"
mock_hopper.assert_not_called() mock_hopper.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -90,3 +91,32 @@ async def test_find_package_passes_use_fallback(mock_hopper, mock_list_packages,
data="com.example.app1\ncom.example.app2", data="com.example.app1\ncom.example.app2",
use_fallback=False, use_fallback=False,
) )
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_model_failure_is_reported_without_poisoning_package_cache(
mock_hopper, mock_list_packages, mock_context
):
mock_list_packages.return_value = "com.example.app1"
mock_hopper.side_effect = HopperError("模型未能完成数据提取,请查看模型请求诊断。")
result = await launch_app.execute(ctx=mock_context, app_name="App 1")
assert "模型未能完成数据提取" in result
assert "Package not found" not in result
assert "App 1" not in mock_context.package_cache
mock_hopper.side_effect = None
mock_hopper.return_value = HopperOutput(found=True, output="com.example.app1", reason="Found")
assert await find_package(mock_context, "App 1") == "com.example.app1"
@pytest.mark.asyncio
@patch("artemis.tools.mobile.launch_app.list_packages_async")
@patch("artemis.tools.mobile.launch_app.hopper")
async def test_invented_package_is_never_cached(mock_hopper, mock_list_packages, mock_context):
mock_list_packages.return_value = "com.example.app1"
mock_hopper.return_value = HopperOutput(
found=True, output="com.example.missing", reason="Guess"
)
assert await find_package(mock_context, "App 1") is None
assert "App 1" not in mock_context.package_cache
Generated
+2 -2
View File
@@ -248,7 +248,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.6.0" version = "2.7.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },
@@ -369,7 +369,7 @@ requires-dist = [
{ name = "uiautomator2", specifier = ">=3.5.0" }, { name = "uiautomator2", specifier = ">=3.5.0" },
{ name = "uuid-utils", specifier = ">=0.9.0" }, { name = "uuid-utils", specifier = ">=0.9.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.1" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.1" },
{ name = "websockets", specifier = ">=12.0" }, { name = "websockets", specifier = ">=15.0" },
{ name = "windows-capture", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'", specifier = ">=2.0.1,<3" }, { name = "windows-capture", marker = "platform_machine == 'AMD64' and sys_platform == 'win32'", specifier = ">=2.0.1,<3" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]