feat: add tool-call fallback and resilient batch exports

This commit is contained in:
chenmingxuan committed 2026-09-29 19:53:20 +08:00
1 parent ab066733b8
commit d31eb2a19d
32 files changed
+530 -64

No files matched your search

+5
View File
@@ -11,6 +11,11 @@ ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
OPENAI_MAX_RETRIES=5
OPENAI_RETRY_INTERVAL_SECONDS=10
OPENAI_RATE_LIMIT_RETRY_SECONDS=60
OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna
OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high
OPENAI_TOOL_FALLBACK_MAX_RETRIES=3
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
OPENAI_REASONING_EFFORT=high
+1 -1
View File
@@ -48,7 +48,7 @@ cd Artemis-Eapil
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
+30 -11
View File
@@ -517,9 +517,12 @@ async def get_model_config_and_env():
@router.get("/server-status")
async def get_server_runtime_status():
"""Retrieve runtime status, PID, port, and uptime of the Artemis server."""
"""Return this server's status without scanning listening ports."""
import os
from artemis.runtime.server_lifecycle import get_server_status
import time
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.server_lifecycle import read_server_info
try:
from apps.admin_console.core.state import state
@@ -527,18 +530,34 @@ async def get_server_runtime_status():
from admin_console.core.state import state
port = getattr(state, "port", 8000)
status = get_server_status(port=port)
current_pid = os.getpid()
pids = {current_pid}
started_at = None
info = read_server_info()
if info and info.get("port") == port:
saved_pid = info.get("pid")
if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid):
pids.add(saved_pid)
if saved_pid == current_pid and isinstance(info.get("started_at"), (int, float)):
started_at = float(info["started_at"])
if started_at is None:
try:
import psutil
started_at = psutil.Process(current_pid).create_time()
except Exception:
started_at = None
# Explicit DTO: the raw metadata file additionally holds the lifecycle
# token, cmdline, and filesystem paths, none of which belong on the wire.
return {
"running": status["running"],
"port": status["port"],
"pids": status["pids"],
"active_pid": status["active_pid"],
"uptime_seconds": status["uptime_seconds"],
"url": status["url"],
"admin_url": status["admin_url"],
"current_pid": os.getpid(),
"running": True,
"port": port,
"pids": sorted(pids),
"active_pid": current_pid,
"uptime_seconds": max(0.0, time.time() - started_at) if started_at else None,
"url": f"http://localhost:{port}",
"admin_url": f"http://localhost:{port}/admin",
"current_pid": current_pid,
}
@@ -23,6 +23,13 @@ class ModelSettingsUpdate(BaseModel):
timeout_seconds: float = Field(default=300, gt=0, le=3600)
max_retries: int = Field(default=5, ge=0, le=10)
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
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_reasoning_effort: (
Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
) = None
tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10)
tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10)
reasoning_effort: Literal[
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
] = "high"
@@ -45,9 +52,11 @@ class ModelSettingsUpdate(BaseModel):
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
return value
@field_validator("model")
@field_validator("model", "tool_fallback_model")
@classmethod
def validate_model(cls, value: str) -> str:
def validate_model(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value or any(c in value for c in "\r\n\x00"):
raise ValueError("模型名称不能为空或包含换行")
@@ -64,6 +73,11 @@ def get_model_settings() -> dict:
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
"max_retries": settings.OPENAI_MAX_RETRIES,
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
"tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT,
"tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES,
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
"model_presets": [
"gpt-5.6-luna",
@@ -86,6 +100,20 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
}
optional_settings = {
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
"tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int),
"tool_fallback_no_progress_attempts": (
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
int,
),
}
for field_name, (env_name, expected_type) in optional_settings.items():
value = getattr(update, field_name)
if value is not None:
values[env_name] = str(value)
if update.api_key and update.api_key.get_secret_value().strip():
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
persist_environment(values)
@@ -95,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
settings.OPENAI_MAX_RETRIES = update.max_retries
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
for field_name, (env_name, expected_type) in optional_settings.items():
value = getattr(update, field_name)
if value is not None:
setattr(settings, env_name, expected_type(value))
if "OPENAI_API_KEY" in values:
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
from artemis.llm.router import ModelFactory
+6 -3
View File
@@ -734,9 +734,12 @@ def export_batch(batch_id: str) -> Path:
for path in outputs:
archive.write(path, path.name)
for number, report in enumerate(records, 1):
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
for path in export_session(report["id"], formats):
archive.write(
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}"
)
archive.write(path, f"{prefix}/{path.name}")
for source, name in session_screenshots(report):
archive.write(source, f"{prefix}/步骤截图/{name}")
for source, name in session_videos(report):
archive.write(source, f"{prefix}/任务录屏/{name}")
temporary.replace(target)
return target
@@ -8,6 +8,8 @@ describe('持久化模型与功能设置', () => {
let http: HttpTestingController;
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high',
tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3,
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
@@ -31,6 +33,9 @@ describe('持久化模型与功能设置', () => {
expect(request.request.body.timeout_seconds).toBe(300);
expect(request.request.body.max_retries).toBe(5);
expect(request.request.body.retry_interval_seconds).toBe(10);
expect(request.request.body.rate_limit_retry_seconds).toBe(60);
expect(request.request.body.tool_fallback_model).toBe('gpt-6-luna');
expect(request.request.body.tool_fallback_no_progress_attempts).toBe(3);
expect(request.request.body.api_key).toBeUndefined();
request.flush({ ...config, model: 'another-model' });
expect(component.busy()).toBeNull();
@@ -88,6 +93,29 @@ describe('持久化模型与功能设置', () => {
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
});
it('高级稳定性设置可编辑并保存', async () => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/model-settings').flush(config);
fixture.detectChanges();
await fixture.whenStable();
const root: HTMLElement = fixture.nativeElement;
root.querySelector<HTMLDetailsElement>('.recovery-settings')!.open = true;
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
fallback.value = 'custom/fallback';
fallback.dispatchEvent(new Event('input'));
const wait = root.querySelector<HTMLInputElement>('input[name="rateLimitRetry"]')!;
wait.value = '90';
wait.dispatchEvent(new Event('input'));
fixture.detectChanges();
await fixture.whenStable();
root.querySelector<HTMLFormElement>('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
const request = http.expectOne('/api/system/model-settings');
expect(request.request.body.tool_fallback_model).toBe('custom/fallback');
expect(request.request.body.rate_limit_retry_seconds).toBe(90);
request.flush({ ...config, tool_fallback_model: 'custom/fallback', rate_limit_retry_seconds: 90 });
});
it('连接测试使用当前输入而不写入保存接口', () => {
const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges();
@@ -10,6 +10,11 @@ interface ModelSettings {
timeout_seconds: number;
max_retries: number;
retry_interval_seconds: number;
rate_limit_retry_seconds: number;
tool_fallback_model: string;
tool_fallback_reasoning_effort: string;
tool_fallback_max_retries: number;
tool_fallback_no_progress_attempts: number;
reasoning_effort: string;
model_presets: string[];
env_path: string;
@@ -56,6 +61,30 @@ interface ModelSettings {
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
</div>
<details class="recovery-settings">
<summary>稳定性与兜底模型</summary>
<div class="recovery-content">
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
[disabled]="busy() || loading()" /></label>
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
[disabled]="busy() || loading()">
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
<option value="medium">中(medium)</option><option value="high">高(high)</option>
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
</select></label>
<div class="number-fields">
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
<label>兜底无效调用上限<input name="toolFallbackNoProgress" type="number" min="1" max="10" required
[(ngModel)]="toolFallbackNoProgress" [disabled]="busy() || loading()" /></label>
</div>
<small>首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。</small>
</div>
</details>
<div class="actions">
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
@@ -77,6 +106,10 @@ interface ModelSettings {
input:focus, select:focus { outline: 2px solid #38bdf8; }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
.number-fields input { margin-top: 8px; }
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
.actions { display: flex; gap: 12px; margin-top: 8px; }
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
@@ -93,6 +126,8 @@ export class ModelSettingsComponent implements OnInit {
saved = output<void>();
modelPresets: string[] = [];
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
busy = signal<'save' | 'test' | null>(null);
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
message = signal(''); error = signal('');
@@ -105,16 +140,27 @@ export class ModelSettingsComponent implements OnInit {
}
valid(): boolean {
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300;
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300
&& this.rateLimitRetry >= 0 && this.rateLimitRetry <= 3600 && !!this.toolFallbackModel.trim()
&& Number.isInteger(this.toolFallbackRetries) && this.toolFallbackRetries >= 0 && this.toolFallbackRetries <= 10
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
}
private payload() {
return { base_url: this.baseUrl.trim(), 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(),
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
tool_fallback_no_progress_attempts: this.toolFallbackNoProgress };
}
private apply(config: ModelSettings): void {
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
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.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
this.toolFallbackModel = config.tool_fallback_model ?? 'gpt-6-luna';
this.toolFallbackReasoning = config.tool_fallback_reasoning_effort ?? 'high';
this.toolFallbackRetries = config.tool_fallback_max_retries ?? 3;
this.toolFallbackNoProgress = config.tool_fallback_no_progress_attempts ?? 3;
}
save(): void {
if (this.busy() || !this.valid()) return;
+40 -5
View File
@@ -1071,6 +1071,8 @@ class FlashRunner:
pending_notices: list[str] = []
no_tool_attempts = 0
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
fallback_llm = None
using_tool_fallback = False
while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None)
@@ -1080,6 +1082,9 @@ class FlashRunner:
on_resume=getattr(self.ctx, "on_task_resume", None),
)
if resumed:
using_tool_fallback = False
no_tool_attempts = 0
self._llm = llm
(
shot_path,
current_pre_screenshot_bytes,
@@ -1131,14 +1136,18 @@ class FlashRunner:
if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly(
self._invoke_model(llm, current_tools, messages),
self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
else:
interrupted, response = (
False,
await self._invoke_model(llm, current_tools, messages),
await self._invoke_model(
fallback_llm if using_tool_fallback else llm, current_tools, messages
),
)
if interrupted or (
@@ -1146,6 +1155,9 @@ class FlashRunner:
):
# Do not dispatch a stale action or final answer from a prompt
# assembled before the instruction arrived.
using_tool_fallback = False
no_tool_attempts = 0
self._llm = llm
(
shot_path,
current_pre_screenshot_bytes,
@@ -1186,6 +1198,9 @@ class FlashRunner:
tool_calls = self._resolve_tool_calls(response, raw_text)
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
using_tool_fallback = False
no_tool_attempts = 0
self._llm = llm
(
shot_path,
current_pre_screenshot_bytes,
@@ -1207,15 +1222,27 @@ class FlashRunner:
final_report = {"status": "failed", "explanation": raw_text}
break
no_tool_attempts += 1
if no_tool_attempts >= no_tool_limit:
active_limit = (
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
if using_tool_fallback
else no_tool_limit
)
if no_tool_attempts >= active_limit:
if using_tool_fallback:
final_report = {
"status": "failed",
"explanation": (
f"模型连续 {no_tool_attempts} 次未调用工具,"
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
f"首选模型连续 {no_tool_limit} 次、兜底模型连续 "
f"{no_tool_attempts} 次未调用工具,已停止任务;"
"未执行新的设备动作。"
),
}
break
fallback_llm = get_llm(self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True
no_tool_attempts = 0
logger.warning("Flash 首选模型未调用工具,当前步骤切换到兜底模型")
pending_notices.append("首选模型连续未调用工具;本步骤改用兜底模型。")
pending_notices.append(_NO_TOOL_CALL_NOTICE)
# Keep the observation and any injected user instruction, but
# do not feed the model its own unproductive prose on retry.
@@ -1224,6 +1251,10 @@ class FlashRunner:
continue
no_tool_attempts = 0
if using_tool_fallback:
# A tool-producing turn completes the fallback's scope. The next
# observation goes back to the configured primary model.
self._llm = fallback_llm
# Process tool calls
turn = _TurnRecord()
(
@@ -1244,6 +1275,10 @@ class FlashRunner:
injected=injected,
native_text=native_text,
)
if using_tool_fallback and any(status == "success" for _, status, _ in turn.actions):
using_tool_fallback = False
self._llm = llm
pending_notices.append("兜底模型已完成本步骤;后续步骤恢复首选模型。")
ledger.stage_turn(messages[turn_base:])
previous_turn = turn
if final_report_from_calls is not None:
+38 -7
View File
@@ -508,6 +508,9 @@ class OperatorNode:
plan_gate_bounced = False
no_progress_attempts = 0
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
fallback_limit = settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
active_llm = base_llm
using_tool_fallback = False
for iteration in range(max_iterations):
control = getattr(self.ctx, "task_control", None)
@@ -532,7 +535,7 @@ class OperatorNode:
)
)
bound_llm = base_llm.bind_tools(tools=traced_tools)
bound_llm = active_llm.bind_tools(tools=traced_tools)
interrupted, response = await self._await_or_pause(
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
)
@@ -607,11 +610,25 @@ class OperatorNode:
if not response.tool_calls:
no_progress_attempts += 1
if no_progress_attempts >= no_progress_limit:
if no_progress_attempts >= (
fallback_limit if using_tool_fallback else no_progress_limit
):
if using_tool_fallback:
raise OperatorNoProgressError(
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
"已停止任务以避免空转;未执行新的设备动作。"
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
f"{no_progress_attempts} 次未提供可执行的工具调用;"
"已停止任务以避免空转。"
)
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True
no_progress_attempts = 0
logger.warning("Operator 首选模型未调用工具,当前步骤切换到兜底模型")
current_messages.append(
SystemMessage(
content="本步骤已切换兜底模型。请使用工具完成操作或明确报告阻碍。"
)
)
continue
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
current_messages.append(response)
current_messages.append(
@@ -931,11 +948,25 @@ class OperatorNode:
if validation_errors:
no_progress_attempts += 1
if no_progress_attempts >= no_progress_limit:
if no_progress_attempts >= (
fallback_limit if using_tool_fallback else no_progress_limit
):
if using_tool_fallback:
raise OperatorNoProgressError(
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
f"首选模型连续 {no_progress_limit} 次、兜底模型连续 "
f"{no_progress_attempts} 次未提供可执行的工具调用,"
"工具参数校验仍失败;已停止任务。"
)
active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True)
using_tool_fallback = True
no_progress_attempts = 0
logger.warning("Operator 首选模型工具调用校验失败,当前步骤切换到兜底模型")
current_messages.append(
SystemMessage(
content="本步骤已切换兜底模型。请修正上面的工具参数后再调用。"
)
)
continue
logger.warning("Some action calls failed validation. Feeding back to LLM.")
continue
no_progress_attempts = 0
+5
View File
@@ -100,6 +100,11 @@ class Settings(BaseSettings):
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
OPENAI_RETRY_INTERVAL_SECONDS: float = Field(default=10.0, ge=0, le=300)
OPENAI_RATE_LIMIT_RETRY_SECONDS: float = Field(default=60.0, ge=0, le=3600)
OPENAI_TOOL_FALLBACK_MODEL: str = Field(default="gpt-6-luna", min_length=1)
OPENAI_TOOL_FALLBACK_REASONING_EFFORT: str = "high"
OPENAI_TOOL_FALLBACK_MAX_RETRIES: int = Field(default=3, ge=0, le=10)
ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=10)
OPENAI_REASONING_EFFORT: str = "high"
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
+5
View File
@@ -117,6 +117,11 @@ def init_command() -> None:
"OPENAI_TIMEOUT_SECONDS=300",
"OPENAI_MAX_RETRIES=5",
"OPENAI_RETRY_INTERVAL_SECONDS=10",
"OPENAI_RATE_LIMIT_RETRY_SECONDS=60",
"OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna",
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high",
"OPENAI_TOOL_FALLBACK_MAX_RETRIES=3",
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3",
"ARTEMIS_VIDEO_ENABLED=false",
"ARTEMIS_TRACES_DIR=./traces",
"",
+5 -1
View File
@@ -75,7 +75,7 @@ def extract_status_code(error: BaseException) -> int | None:
return None
_RATE_LIMIT_MARKERS = ("rate limit", "quota", "resource_exhausted")
_RATE_LIMIT_MARKERS = ("rate limit", "too many requests", "quota", "resource_exhausted")
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
@@ -196,6 +196,10 @@ def retry_policy_for(category: FailureCategory | str) -> RetryPolicy:
return _DEFAULT_RETRY_POLICIES[category]
from artemis.config.settings import settings
if category is FailureCategory.RATE_LIMIT:
return _configured_policy(
settings.OPENAI_MAX_RETRIES, settings.OPENAI_RATE_LIMIT_RETRY_SECONDS
)
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
+24 -13
View File
@@ -15,25 +15,36 @@
"""Backward compatibility entrypoint for artemis CLI."""
import sys
import typer
from artemis.interfaces.cli.main import app
from artemis.interfaces.cli.main import app as _typer_app
def _root_tokens() -> frozenset[str]:
"""Read command names and root flags from the registered CLI."""
group = typer.main.get_command(_typer_app)
tokens: set[str] = {"-h"}
tokens.update(getattr(group, "commands", {}) or {})
tokens.update(group.get_help_option_names(group.context_class(group)))
for param in group.params:
tokens.update(getattr(param, "opts", ()))
tokens.update(getattr(param, "secondary_opts", ()))
return frozenset(tokens)
def normalize_argv(argv: list[str]) -> list[str]:
"""Keep implicit ``run`` for a goal while preserving registered commands."""
if len(argv) > 1 and argv[1] not in _root_tokens():
return [argv[0], "run", *argv[1:]]
return list(argv)
def cli():
# If invoked directly as python -m artemis.main without subcommand 'run',
# check if first argument is a goal rather than a subcommand
if len(sys.argv) > 1 and sys.argv[1] not in (
"run",
"init",
"doctor",
"batch",
"bench",
"mcp",
"server",
"trace",
"--help",
"-h",
):
sys.argv.insert(1, "run")
sys.argv[:] = normalize_argv(sys.argv)
app()
+8 -1
View File
@@ -13,7 +13,14 @@
"presets": {},
"nodes": {
"planner": {},
"operator": {},
"operator": {
"fallback": {
"provider": "openai",
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
}
},
"video_analyzer": {},
"object_detector": {},
"explorer": {},
+1 -1
View File
@@ -66,7 +66,7 @@ def is_daemon_running(
def is_artemis_daemon(
host: str = DEFAULT_DAEMON_HOST,
port: int = DEFAULT_DAEMON_PORT,
timeout: float = 0.5,
timeout: float = 2.0,
) -> bool:
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
+2 -2
View File
@@ -615,9 +615,9 @@ async def render_timeline_clip(
)
labels.append(f"[{label}]")
if len(labels) == 1:
filter_parts.append(f"{labels[0]}null[outv]")
filter_parts.append(f"{labels[0]}fps={fps}[outv]")
else:
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0[outv]")
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0,fps={fps}[outv]")
command.extend(
[
+8 -1
View File
@@ -13,7 +13,14 @@
"presets": {},
"nodes": {
"planner": {},
"operator": {},
"operator": {
"fallback": {
"provider": "openai",
"model": "${env:OPENAI_TOOL_FALLBACK_MODEL:-gpt-6-luna}",
"reasoning_effort": "${env:OPENAI_TOOL_FALLBACK_REASONING_EFFORT:-high}",
"max_retries": "${env:OPENAI_TOOL_FALLBACK_MAX_RETRIES:-3}"
}
},
"video_analyzer": {},
"object_detector": {},
"explorer": {},
+3 -1
View File
@@ -42,7 +42,9 @@ MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
模型默认使用 `mimo-v2.6-flash`,也预设 `gpt-5.6-luna` 并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,每次等待 10 秒,SDK 不叠加重试。耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
模型默认使用 `mimo-v2.6-flash`,预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-pro`,并支持自定义。思考强度最高支持 `max`,默认 `high`,选“由模型默认”不发送该参数;接口不支持时明确报错。单次请求 300 秒,首次失败后最多再试 5 次,普通错误每次等待 10 秒,429 每次等待 60 秒,SDK 不叠加重试。首选模型连续 3 次没有有效工具调用时,当前步骤改用 `gpt-6-luna`(high),最多再试 3 次;下一步重新使用首选模型。兜底模型及次数可通过 `.env.example` 中的 `OPENAI_TOOL_FALLBACK_*` 和 `ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS` 配置。请求耗尽后沿用任务暂停/恢复机制,鉴权或非法参数立即失败。视频分析与录屏是独立开关,默认均关闭。每项批量用例可覆盖模式、模型、设备、录屏和超时等配置,不影响其他任务。暂停先返回 `pausing`,确认状态为 `task_paused` 才表示设备已释放;Pro 等待模型回复或只读诊断时可及时中断,恢复后重新观察设备。AI 可用 `mobile_manage_task(action="inject_instruction")` 提交指导并恢复;运行中的模型思考会中断并重新观察设备,已经开始的设备动作会安全结束。用 `guidance_history` 核对指导是否被领取、在任务流程中核对实际执行情况,或用 `resume` 单独恢复。
429 等待、兜底模型及次数也可在网页“模型配置 → 稳定性与兜底模型”或通过已授权的 `mobile_config(section="model")` 配置。批次 ZIP 还收录每项任务已保存的步骤截图和录屏。
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
+1 -1
View File
@@ -24,7 +24,7 @@
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
+9 -1
View File
@@ -19,6 +19,11 @@ MODEL_FIELDS = {
"timeout_seconds",
"max_retries",
"retry_interval_seconds",
"rate_limit_retry_seconds",
"tool_fallback_model",
"tool_fallback_reasoning_effort",
"tool_fallback_max_retries",
"tool_fallback_no_progress_attempts",
"reasoning_effort",
}
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
@@ -122,7 +127,10 @@ async def mobile_config(
Only set persist_defaults=true when the user requests saved/global configuration.
model changes: model (preset or custom), base_url, reasoning_effort
(default/none/minimal/low/medium/high/xhigh/max), timeout_seconds, max_retries,
retry_interval_seconds. 'default' omits reasoning; unsupported values fail visibly.
retry_interval_seconds, rate_limit_retry_seconds, tool_fallback_model,
tool_fallback_reasoning_effort, tool_fallback_max_retries,
tool_fallback_no_progress_attempts. 'default' omits reasoning;
unsupported values fail visibly.
capabilities: video_enabled (recording + analysis), outputter_enabled,
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one).
API keys are never accepted or returned: use the Web UI or .env for credentials.
+3 -3
View File
@@ -19,7 +19,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
## 结果、证据与交付
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总和各项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;`formats=["screenshots"]` 获取排序截图,`["video"]` 获取保存的录屏分段,`["all"]` 获取 Word、Excel 和所有可用资料的 ZIP。批次传 `batch_id` 获取汇总、各项任务报告、步骤截图及已有录屏的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
@@ -27,10 +27,10 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
## 配置和恢复边界
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
- 模型名称支持预设 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro` 和自定义名称,取决于用户配置的 Responses 网关。思考强度最高支持 `max`,默认 `high`、单次请求 300 秒、失败后最多重试 5 次;普通错误每次等待 10 秒,429 每次等待 60 秒。首选模型连续 3 次未形成有效工具调用时,当前步骤改用 `gpt-6-luna`(high),兜底最多再尝试 3 次;下一步恢复首选模型。默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
- 录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
- 429 等待和兜底参数可在网页模型配置或经用户明确授权的 `mobile_config(section="model", persist_defaults=true)` 保存;当前任务不热切换。录屏与 AI 视频分析分别配置。批量任务的 `task_config` 可覆盖单项,不修改全局设置;只开视频分析时录制内容不作为可下载文件保存。暂停时停止录屏,恢复后按片段继续,不能把其他任务画面混入该任务资料。
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
- 批次暂停只停止后续派发;当前任务等待指导时用 `mobile_manage_task(action="pause", trace_id=...)`,真正取消用 `stop`。用户明确要求删除批次记录时先 `mobile_batch(action="get")` 核对编号,再用 `delete`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
@@ -60,6 +60,6 @@
## 报告
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总、各项报告和有序步骤截图;任务保存了录屏时,还包含对应录屏片段。报告格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`、`["screenshots"]`、`["video"]` 或 `["all"]`。默认格式通过 `mobile_config(section="reports")` 查看。录屏与 AI 视频分析独立,只有保存了录屏才可下载片段。
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
@@ -318,6 +318,38 @@ async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
def test_batch_archive_contains_each_task_media_when_present(isolated, monkeypatch, tmp_path):
batch = service.create_batch(payload())
first, second = isolated.jobs(batch["id"])
image1 = tmp_path / "first.png"
image2 = tmp_path / "second.png"
video = tmp_path / "recording.mp4"
image1.write_bytes(b"first screenshot")
image2.write_bytes(b"second screenshot")
video.write_bytes(b"saved recording")
images = {first["session_id"]: image1, second["session_id"]: image2}
monkeypatch.setattr(
reports,
"session_screenshots",
lambda report: [(images[report["id"]], "0001_步骤0001_操作前.png")],
)
monkeypatch.setattr(
reports,
"session_videos",
lambda report: (
[(video, "0001_recording.mp4")] if report["id"] == first["session_id"] else []
),
)
archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z:
names = z.namelist()
assert len([name for name in names if "/步骤截图/" in name]) == 2
assert len([name for name in names if "/任务录屏/" in name]) == 1
assert all(name.startswith("任务报告/") for name in names if "步骤截图" in name)
assert z.read(next(name for name in names if "/任务录屏/" in name)) == b"saved recording"
@pytest.mark.asyncio
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
@@ -23,6 +23,11 @@ def isolated_settings(monkeypatch, tmp_path):
"OPENAI_TIMEOUT_SECONDS",
"OPENAI_MAX_RETRIES",
"OPENAI_RETRY_INTERVAL_SECONDS",
"OPENAI_RATE_LIMIT_RETRY_SECONDS",
"OPENAI_TOOL_FALLBACK_MODEL",
"OPENAI_TOOL_FALLBACK_REASONING_EFFORT",
"OPENAI_TOOL_FALLBACK_MAX_RETRIES",
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
"OPENAI_REASONING_EFFORT",
"ARTEMIS_VIDEO_ENABLED",
"ARTEMIS_OUTPUTTER_ENABLED",
@@ -43,6 +48,11 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
api_key="new-test-secret",
timeout_seconds=75,
max_retries=2,
rate_limit_retry_seconds=90,
tool_fallback_model="custom/fallback",
tool_fallback_reasoning_effort="max",
tool_fallback_max_retries=2,
tool_fallback_no_progress_attempts=4,
)
result = service.save_model_settings(update)
assert result["api_key_configured"]
@@ -50,12 +60,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
saved = dotenv_values(isolated_settings)
assert saved["OPENAI_API_KEY"] == "new-test-secret"
assert saved["OPENAI_MODEL"] == "my-model"
assert saved["OPENAI_RATE_LIMIT_RETRY_SECONDS"] == "90.0"
assert saved["OPENAI_TOOL_FALLBACK_MODEL"] == "custom/fallback"
assert saved["ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS"] == "4"
assert saved["UNRELATED"] == "keep"
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
reloaded = Settings(_env_file=isolated_settings)
assert reloaded.OPENAI_MODEL == "my-model"
assert parse_llm_config().operator.model == "my-model"
assert parse_llm_config().operator.timeout == 75
assert parse_llm_config().operator.fallback.model == "custom/fallback"
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
+50 -4
View File
@@ -384,7 +384,7 @@ def _make_runner(mock_context, responses, max_turns=None):
pending = list(responses)
async def _invoke(llm, tools, messages):
runner.calls.append({"tools": list(tools), "messages": list(messages)})
runner.calls.append({"llm": llm, "tools": list(tools), "messages": list(messages)})
return pending.pop(0)
runner._invoke_model = AsyncMock(side_effect=_invoke)
@@ -647,9 +647,11 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
from artemis.config.settings import settings
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts + 3)]
with (
patch("artemis.controllers.unified_controller.get_driver"),
patch("artemis.agents.flash.runner.get_llm", return_value=Mock()) as get_fallback,
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
@@ -659,11 +661,55 @@ async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch,
result = await runner.run(State(initial_goal="Open Settings"))
assert result["status"] == "failed"
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
assert runner._invoke_model.await_count == attempts
assert f"连续 {attempts} 次" in result["explanation"]
assert "兜底模型连续 3 次" in result["explanation"]
assert runner._invoke_model.await_count == attempts + 3
get_fallback.assert_called_once_with(mock_context, name="operator", use_fallback=True)
runner.executor.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_flash_uses_fallback_for_one_tool_turn_then_primary(mock_context):
no_call = AIMessage(content="I will click now.")
observe = AIMessage(
content="Check the target.",
tool_calls=[
{
"name": "ask_explorer",
"args": {"question": "Find Settings"},
"id": "fallback-observe",
}
],
)
click = AIMessage(
content="Tap Settings.",
tool_calls=[{"name": "click", "args": {"target": 1}, "id": "fallback-click"}],
)
fallback = Mock()
responses = [no_call, no_call, no_call, observe, click, _report()]
with (
patch("artemis.controllers.unified_controller.get_driver"),
patch("artemis.agents.flash.runner.get_llm", return_value=fallback),
patch(
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
AsyncMock(return_value=_INITIAL_OBSERVATION),
),
):
runner = _make_runner(mock_context, responses, max_turns=0)
result = await runner.run(State(initial_goal="Open Settings"))
primary = runner.calls[0]["llm"]
assert [call["llm"] for call in runner.calls] == [
primary,
primary,
primary,
fallback,
fallback,
primary,
]
assert result["status"] == "completed"
# ---------------------------------------------------------------------------
# _TurnRecord.result: the turn's execution result in the validator-report shape
# ---------------------------------------------------------------------------
+36 -3
View File
@@ -107,23 +107,56 @@ async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatc
@pytest.mark.parametrize("limit", [1, 3])
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
monkeypatch.setattr(settings, "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", 3)
fallback = Mock()
get_fallback = Mock(return_value=fallback)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
await node._invoke_llm_loop(Mock(), [], [], [], state)
assert invoke.await_count == limit
assert invoke.await_count == limit + 3
get_fallback.assert_called_once_with(ctx=node.ctx, name="operator", use_fallback=True)
assert invoke.call_args_list[-1].args[0] is fallback.bind_tools.return_value
@pytest.mark.asyncio
async def test_operator_fallback_is_scoped_to_current_step(operator, monkeypatch):
node, state = operator
primary = Mock()
fallback = Mock()
get_fallback = Mock(return_value=fallback)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", get_fallback)
invoke = AsyncMock(side_effect=[AIMessage(content="no tool")] * 3 + [click(), click()])
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
first = await node._invoke_llm_loop(primary, [], [], [], state)
second = await node._invoke_llm_loop(primary, [], [], [], state)
assert first[0] and second[0]
assert get_fallback.call_count == 1
assert [call.args[0] for call in invoke.call_args_list] == [
primary.bind_tools.return_value,
primary.bind_tools.return_value,
primary.bind_tools.return_value,
fallback.bind_tools.return_value,
primary.bind_tools.return_value,
]
@pytest.mark.asyncio
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
invoke = AsyncMock(
side_effect=[click("missing"), AIMessage(content="Retry."), click("14")] + [click("14")] * 3
)
monkeypatch.setattr("artemis.agents.operator.operator.get_llm", Mock(return_value=Mock()))
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
node, state = operator
messages = []
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
await node._invoke_llm_loop(Mock(), messages, [], [], state)
assert invoke.await_count == 3
assert invoke.await_count == 6
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
+8
View File
@@ -32,6 +32,14 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
"reasoning_effort": "high",
}
await tools.mobile_config(
changes={"rate_limit_retry_seconds": 90, "tool_fallback_model": "gpt-6-luna"},
persist_defaults=True,
)
payload = request.call_args.kwargs["json"]
assert payload["rate_limit_retry_seconds"] == 90
assert payload["tool_fallback_model"] == "gpt-6-luna"
@pytest.mark.asyncio
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
@@ -225,6 +225,20 @@ async def test_api_server_status():
assert "current_pid" in data
@pytest.mark.asyncio
async def test_api_server_status_does_not_scan_ports():
with patch(
"artemis.runtime.server_lifecycle.get_server_status",
side_effect=AssertionError("port scan"),
):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://localhost"
) as client:
response = await client.get("/api/system/server-status")
assert response.status_code == 200
assert response.json()["running"] is True
@pytest.mark.asyncio
async def test_api_restart_endpoint():
"""Verify POST /api/system/restart accepts request and schedules restart."""
+26
View File
@@ -301,6 +301,32 @@ async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypat
assert llm_service._FAILED_ENDPOINTS.get() is None
@pytest.mark.asyncio
async def test_429_waits_one_minute_then_retries(monkeypatch):
from unittest.mock import AsyncMock
from artemis.llm.reliability import retry_policy_for
class RateLimited(Exception):
status_code = 429
calls = 0
async def call_once():
nonlocal calls
calls += 1
if calls == 1:
raise RateLimited("Too Many Requests")
return "recovered"
sleep = AsyncMock()
monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for)
monkeypatch.setattr(llm_service.asyncio, "sleep", sleep)
monkeypatch.setattr(llm_service.settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
assert await llm_service._run_with_recovery(call_once, max_retries=1) == "recovered"
assert calls == 2
sleep.assert_awaited_once_with(60)
@pytest.mark.asyncio
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
import asyncio
+9
View File
@@ -49,6 +49,15 @@ def test_non_retryable_categories_get_single_attempt():
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
def test_too_many_requests_uses_one_minute_retry_delay(monkeypatch):
from artemis.config.settings import settings
monkeypatch.setattr(settings, "OPENAI_RATE_LIMIT_RETRY_SECONDS", 60)
failure = classify_failure(RuntimeError("Too Many Requests"))
assert failure.category is FailureCategory.RATE_LIMIT
assert retry_policy_for(failure.category).delay_for(1) == 60
def test_breaker_only_trips_on_transient_categories():
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
auth = classify_failure(RuntimeError("401 unauthorized"))
+12
View File
@@ -0,0 +1,12 @@
"""The compatibility entry point follows the commands registered by Typer."""
from artemis.main import normalize_argv
def test_registered_commands_and_flags_are_not_treated_as_goals():
for token in ("ui", "status", "helper", "restart", "stop", "--help", "--version", "-v"):
assert normalize_argv(["artemis", token]) == ["artemis", token]
def test_bare_goal_still_uses_run_command():
assert normalize_argv(["artemis", "打开设置"]) == ["artemis", "run", "打开设置"]
@@ -207,6 +207,30 @@ async def test_analyzer_clip_stays_continuous_across_orientation_segments(tmp_pa
assert len(frames) >= 20
@pytest.mark.asyncio
async def test_analyzer_clip_pins_requested_fps_across_recording_gap(tmp_path):
ffmpeg = get_ffmpeg_path()
first = tmp_path / "first.mkv"
second = tmp_path / "second.mkv"
output = tmp_path / "clip.mp4"
_make_solid_clip(ffmpeg, "red", "108x242", 1.0, first)
_make_solid_clip(ffmpeg, "blue", "242x108", 1.0, second)
assert await render_timeline_clip(
[{"path": first, "start": 0.0, "end": 1.0}, {"path": second, "start": 1.5, "end": 2.5}],
0.0,
2.5,
output,
canvas_width=180,
canvas_height=320,
fps=7,
)
capture = cv2.VideoCapture(str(output))
try:
assert capture.get(cv2.CAP_PROP_FPS) == pytest.approx(7.0, abs=0.1)
finally:
capture.release()
def test_timeline_pieces_fill_restart_gaps(tmp_path):
"""Holes between segments (and a window starting in one) become black gaps."""
first = tmp_path / "recording.mkv"