feat: add tool-call fallback and resilient batch exports
This commit is contained in:
1 parent
ab066733b8
commit
d31eb2a19d
32 files changed
+538
-72
No files matched your search
@@ -11,6 +11,11 @@ ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
|||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=5
|
OPENAI_MAX_RETRIES=5
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
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 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
# default 表示不发送思考强度参数;接口不支持所选值时明确报错。
|
||||||
OPENAI_REASONING_EFFORT=high
|
OPENAI_REASONING_EFFORT=high
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ cd Artemis-Eapil
|
|||||||
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。
|
||||||
4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。
|
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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||||
|
|
||||||
|
|||||||
@@ -517,9 +517,12 @@ async def get_model_config_and_env():
|
|||||||
|
|
||||||
@router.get("/server-status")
|
@router.get("/server-status")
|
||||||
async def get_server_runtime_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
|
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:
|
try:
|
||||||
from apps.admin_console.core.state import state
|
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
|
from admin_console.core.state import state
|
||||||
|
|
||||||
port = getattr(state, "port", 8000)
|
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
|
# Explicit DTO: the raw metadata file additionally holds the lifecycle
|
||||||
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
# token, cmdline, and filesystem paths, none of which belong on the wire.
|
||||||
return {
|
return {
|
||||||
"running": status["running"],
|
"running": True,
|
||||||
"port": status["port"],
|
"port": port,
|
||||||
"pids": status["pids"],
|
"pids": sorted(pids),
|
||||||
"active_pid": status["active_pid"],
|
"active_pid": current_pid,
|
||||||
"uptime_seconds": status["uptime_seconds"],
|
"uptime_seconds": max(0.0, time.time() - started_at) if started_at else None,
|
||||||
"url": status["url"],
|
"url": f"http://localhost:{port}",
|
||||||
"admin_url": status["admin_url"],
|
"admin_url": f"http://localhost:{port}/admin",
|
||||||
"current_pid": os.getpid(),
|
"current_pid": current_pid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
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)
|
||||||
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)
|
||||||
|
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[
|
reasoning_effort: Literal[
|
||||||
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
||||||
] = "high"
|
] = "high"
|
||||||
@@ -45,9 +52,11 @@ class ModelSettingsUpdate(BaseModel):
|
|||||||
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@field_validator("model")
|
@field_validator("model", "tool_fallback_model")
|
||||||
@classmethod
|
@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()
|
value = value.strip()
|
||||||
if not value or any(c in value for c in "\r\n\x00"):
|
if not value or any(c in value for c in "\r\n\x00"):
|
||||||
raise ValueError("模型名称不能为空或包含换行")
|
raise ValueError("模型名称不能为空或包含换行")
|
||||||
@@ -64,6 +73,11 @@ def get_model_settings() -> dict:
|
|||||||
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||||
"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,
|
||||||
|
"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,
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||||
"model_presets": [
|
"model_presets": [
|
||||||
"gpt-5.6-luna",
|
"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_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
||||||
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
"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():
|
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)
|
||||||
@@ -95,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|||||||
settings.OPENAI_MAX_RETRIES = update.max_retries
|
settings.OPENAI_MAX_RETRIES = update.max_retries
|
||||||
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
||||||
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
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:
|
if "OPENAI_API_KEY" in values:
|
||||||
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
||||||
from artemis.llm.router import ModelFactory
|
from artemis.llm.router import ModelFactory
|
||||||
|
|||||||
@@ -734,9 +734,12 @@ def export_batch(batch_id: str) -> Path:
|
|||||||
for path in outputs:
|
for path in outputs:
|
||||||
archive.write(path, path.name)
|
archive.write(path, path.name)
|
||||||
for number, report in enumerate(records, 1):
|
for number, report in enumerate(records, 1):
|
||||||
|
prefix = f"任务报告/{number:04d}_{archive_case_name(report)}"
|
||||||
for path in export_session(report["id"], formats):
|
for path in export_session(report["id"], formats):
|
||||||
archive.write(
|
archive.write(path, f"{prefix}/{path.name}")
|
||||||
path, f"任务报告/{number:04d}_{archive_case_name(report)}/{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)
|
temporary.replace(target)
|
||||||
return target
|
return target
|
||||||
@@ -8,6 +8,8 @@ describe('持久化模型与功能设置', () => {
|
|||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
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',
|
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' };
|
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
@@ -31,6 +33,9 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(request.request.body.timeout_seconds).toBe(300);
|
expect(request.request.body.timeout_seconds).toBe(300);
|
||||||
expect(request.request.body.max_retries).toBe(5);
|
expect(request.request.body.max_retries).toBe(5);
|
||||||
expect(request.request.body.retry_interval_seconds).toBe(10);
|
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();
|
expect(request.request.body.api_key).toBeUndefined();
|
||||||
request.flush({ ...config, model: 'another-model' });
|
request.flush({ ...config, model: 'another-model' });
|
||||||
expect(component.busy()).toBeNull();
|
expect(component.busy()).toBeNull();
|
||||||
@@ -88,6 +93,29 @@ describe('持久化模型与功能设置', () => {
|
|||||||
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
|
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('连接测试使用当前输入而不写入保存接口', () => {
|
it('连接测试使用当前输入而不写入保存接口', () => {
|
||||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ interface ModelSettings {
|
|||||||
timeout_seconds: number;
|
timeout_seconds: number;
|
||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: 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;
|
reasoning_effort: string;
|
||||||
model_presets: string[];
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
@@ -56,6 +61,30 @@ interface ModelSettings {
|
|||||||
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
||||||
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
||||||
</div>
|
</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">
|
<div class="actions">
|
||||||
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
||||||
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
||||||
@@ -77,6 +106,10 @@ interface ModelSettings {
|
|||||||
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
||||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||||
.number-fields input { 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; }
|
.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 { 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; }
|
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||||
@@ -93,6 +126,8 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
saved = output<void>();
|
saved = output<void>();
|
||||||
modelPresets: string[] = [];
|
modelPresets: string[] = [];
|
||||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
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);
|
busy = signal<'save' | 'test' | null>(null);
|
||||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||||
message = signal(''); error = signal('');
|
message = signal(''); error = signal('');
|
||||||
@@ -105,16 +140,27 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
valid(): boolean {
|
valid(): boolean {
|
||||||
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
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() {
|
private payload() {
|
||||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
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 {
|
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.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.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 {
|
save(): void {
|
||||||
if (this.busy() || !this.valid()) return;
|
if (this.busy() || !this.valid()) return;
|
||||||
|
|||||||
@@ -1071,6 +1071,8 @@ class FlashRunner:
|
|||||||
pending_notices: list[str] = []
|
pending_notices: list[str] = []
|
||||||
no_tool_attempts = 0
|
no_tool_attempts = 0
|
||||||
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
|
fallback_llm = None
|
||||||
|
using_tool_fallback = False
|
||||||
|
|
||||||
while limit is None or turns < limit:
|
while limit is None or turns < limit:
|
||||||
control = getattr(self.ctx, "task_control", None)
|
control = getattr(self.ctx, "task_control", None)
|
||||||
@@ -1080,6 +1082,9 @@ class FlashRunner:
|
|||||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
)
|
)
|
||||||
if resumed:
|
if resumed:
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1131,14 +1136,18 @@ class FlashRunner:
|
|||||||
|
|
||||||
if isinstance(control, TaskControl):
|
if isinstance(control, TaskControl):
|
||||||
interrupted, response = await control.run_interruptibly(
|
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_pause=getattr(self.ctx, "on_task_pause", None),
|
||||||
on_resume=getattr(self.ctx, "on_task_resume", None),
|
on_resume=getattr(self.ctx, "on_task_resume", None),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
interrupted, response = (
|
interrupted, response = (
|
||||||
False,
|
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 (
|
if interrupted or (
|
||||||
@@ -1146,6 +1155,9 @@ class FlashRunner:
|
|||||||
):
|
):
|
||||||
# Do not dispatch a stale action or final answer from a prompt
|
# Do not dispatch a stale action or final answer from a prompt
|
||||||
# assembled before the instruction arrived.
|
# assembled before the instruction arrived.
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1186,6 +1198,9 @@ class FlashRunner:
|
|||||||
|
|
||||||
tool_calls = self._resolve_tool_calls(response, raw_text)
|
tool_calls = self._resolve_tool_calls(response, raw_text)
|
||||||
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
if isinstance(control, TaskControl) and has_pending_instruction(control.directory):
|
||||||
|
using_tool_fallback = False
|
||||||
|
no_tool_attempts = 0
|
||||||
|
self._llm = llm
|
||||||
(
|
(
|
||||||
shot_path,
|
shot_path,
|
||||||
current_pre_screenshot_bytes,
|
current_pre_screenshot_bytes,
|
||||||
@@ -1207,15 +1222,27 @@ class FlashRunner:
|
|||||||
final_report = {"status": "failed", "explanation": raw_text}
|
final_report = {"status": "failed", "explanation": raw_text}
|
||||||
break
|
break
|
||||||
no_tool_attempts += 1
|
no_tool_attempts += 1
|
||||||
if no_tool_attempts >= no_tool_limit:
|
active_limit = (
|
||||||
final_report = {
|
settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS
|
||||||
"status": "failed",
|
if using_tool_fallback
|
||||||
"explanation": (
|
else no_tool_limit
|
||||||
f"模型连续 {no_tool_attempts} 次未调用工具,"
|
)
|
||||||
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
|
if no_tool_attempts >= active_limit:
|
||||||
),
|
if using_tool_fallback:
|
||||||
}
|
final_report = {
|
||||||
break
|
"status": "failed",
|
||||||
|
"explanation": (
|
||||||
|
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)
|
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||||
# Keep the observation and any injected user instruction, but
|
# Keep the observation and any injected user instruction, but
|
||||||
# do not feed the model its own unproductive prose on retry.
|
# do not feed the model its own unproductive prose on retry.
|
||||||
@@ -1224,6 +1251,10 @@ class FlashRunner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
no_tool_attempts = 0
|
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
|
# Process tool calls
|
||||||
turn = _TurnRecord()
|
turn = _TurnRecord()
|
||||||
(
|
(
|
||||||
@@ -1244,6 +1275,10 @@ class FlashRunner:
|
|||||||
injected=injected,
|
injected=injected,
|
||||||
native_text=native_text,
|
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:])
|
ledger.stage_turn(messages[turn_base:])
|
||||||
previous_turn = turn
|
previous_turn = turn
|
||||||
if final_report_from_calls is not None:
|
if final_report_from_calls is not None:
|
||||||
|
|||||||
@@ -508,6 +508,9 @@ class OperatorNode:
|
|||||||
plan_gate_bounced = False
|
plan_gate_bounced = False
|
||||||
no_progress_attempts = 0
|
no_progress_attempts = 0
|
||||||
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
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):
|
for iteration in range(max_iterations):
|
||||||
control = getattr(self.ctx, "task_control", None)
|
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(
|
interrupted, response = await self._await_or_pause(
|
||||||
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
|
||||||
)
|
)
|
||||||
@@ -607,11 +610,25 @@ class OperatorNode:
|
|||||||
|
|
||||||
if not response.tool_calls:
|
if not response.tool_calls:
|
||||||
no_progress_attempts += 1
|
no_progress_attempts += 1
|
||||||
if no_progress_attempts >= no_progress_limit:
|
if no_progress_attempts >= (
|
||||||
raise OperatorNoProgressError(
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
):
|
||||||
"已停止任务以避免空转;未执行新的设备动作。"
|
if using_tool_fallback:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
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.")
|
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||||
current_messages.append(response)
|
current_messages.append(response)
|
||||||
current_messages.append(
|
current_messages.append(
|
||||||
@@ -931,11 +948,25 @@ class OperatorNode:
|
|||||||
|
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
no_progress_attempts += 1
|
no_progress_attempts += 1
|
||||||
if no_progress_attempts >= no_progress_limit:
|
if no_progress_attempts >= (
|
||||||
raise OperatorNoProgressError(
|
fallback_limit if using_tool_fallback else no_progress_limit
|
||||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
):
|
||||||
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
|
if using_tool_fallback:
|
||||||
|
raise OperatorNoProgressError(
|
||||||
|
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.")
|
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||||
continue
|
continue
|
||||||
no_progress_attempts = 0
|
no_progress_attempts = 0
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class Settings(BaseSettings):
|
|||||||
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
|
||||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
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_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"
|
OPENAI_REASONING_EFFORT: str = "high"
|
||||||
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS: float = Field(default=12.0, gt=0)
|
||||||
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
ARTEMIS_MODEL_CACHE_TTL_SECONDS: float = Field(default=10.0, ge=0)
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ def init_command() -> None:
|
|||||||
"OPENAI_TIMEOUT_SECONDS=300",
|
"OPENAI_TIMEOUT_SECONDS=300",
|
||||||
"OPENAI_MAX_RETRIES=5",
|
"OPENAI_MAX_RETRIES=5",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS=10",
|
"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_VIDEO_ENABLED=false",
|
||||||
"ARTEMIS_TRACES_DIR=./traces",
|
"ARTEMIS_TRACES_DIR=./traces",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def extract_status_code(error: BaseException) -> int | None:
|
|||||||
return 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")
|
_UNAVAILABLE_MARKERS = ("unavailable", "overloaded", "high demand", "service unavailable")
|
||||||
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
_AUTH_MARKERS = ("unauthorized", "forbidden", "api key", "credential")
|
||||||
# "deadline exceeded"/"deadline_exceeded" cover google-genai SDK timeouts that
|
# "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]
|
return _DEFAULT_RETRY_POLICIES[category]
|
||||||
from artemis.config.settings import settings
|
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)
|
return _configured_policy(settings.OPENAI_MAX_RETRIES, settings.OPENAI_RETRY_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+24
-13
@@ -15,25 +15,36 @@
|
|||||||
"""Backward compatibility entrypoint for artemis CLI."""
|
"""Backward compatibility entrypoint for artemis CLI."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
from artemis.interfaces.cli.main import app
|
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():
|
def cli():
|
||||||
# If invoked directly as python -m artemis.main without subcommand 'run',
|
# If invoked directly as python -m artemis.main without subcommand 'run',
|
||||||
# check if first argument is a goal rather than a subcommand
|
# check if first argument is a goal rather than a subcommand
|
||||||
if len(sys.argv) > 1 and sys.argv[1] not in (
|
sys.argv[:] = normalize_argv(sys.argv)
|
||||||
"run",
|
|
||||||
"init",
|
|
||||||
"doctor",
|
|
||||||
"batch",
|
|
||||||
"bench",
|
|
||||||
"mcp",
|
|
||||||
"server",
|
|
||||||
"trace",
|
|
||||||
"--help",
|
|
||||||
"-h",
|
|
||||||
):
|
|
||||||
sys.argv.insert(1, "run")
|
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"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": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def is_daemon_running(
|
|||||||
def is_artemis_daemon(
|
def is_artemis_daemon(
|
||||||
host: str = DEFAULT_DAEMON_HOST,
|
host: str = DEFAULT_DAEMON_HOST,
|
||||||
port: int = DEFAULT_DAEMON_PORT,
|
port: int = DEFAULT_DAEMON_PORT,
|
||||||
timeout: float = 0.5,
|
timeout: float = 2.0,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
"""Identity probe: is the process on ``host:port`` the Artemis daemon?
|
||||||
|
|
||||||
|
|||||||
@@ -615,9 +615,9 @@ async def render_timeline_clip(
|
|||||||
)
|
)
|
||||||
labels.append(f"[{label}]")
|
labels.append(f"[{label}]")
|
||||||
if len(labels) == 1:
|
if len(labels) == 1:
|
||||||
filter_parts.append(f"{labels[0]}null[outv]")
|
filter_parts.append(f"{labels[0]}fps={fps}[outv]")
|
||||||
else:
|
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(
|
command.extend(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -13,7 +13,14 @@
|
|||||||
"presets": {},
|
"presets": {},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"planner": {},
|
"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": {},
|
"video_analyzer": {},
|
||||||
"object_detector": {},
|
"object_detector": {},
|
||||||
"explorer": {},
|
"explorer": {},
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ MCP 同步会识别已安装客户端的配置目录,为缺少 ARTEMIS 条目
|
|||||||
|
|
||||||
报告按任务或批次归档到 `artifacts/mcp_exports/`,包含本地路径及服务下载链接;删除批次时会清理对应导出副本。运行中导出是快照;执行完成不等于验证通过,报告会保留未验证、失败、跳过与中断状态。
|
报告按任务或批次归档到 `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` 重复执行。
|
批次任务在队列中使用“用例编号 + 用例名称”显示,提示词仍是模型实际收到的内容。任务历史展示失败原因、完成时间和总耗时;导出全部资料时以用例编号命名目录。删除非活跃批次会同步清理其任务记录、步骤、截图、录屏及生成的报告,删除后不能通过旧 `request_id` 重复执行。
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
服务重启后保留批次,等待用户继续未开始的用例。正在执行或暂停的任务标记中断,不自动重做;“复制失败项为新批次”保留原记录并产生新任务编号。
|
||||||
|
|
||||||
每个任务结束后生成所选格式报告。批次结束后生成汇总及每项任务报告的 ZIP;失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
每个任务结束后生成所选格式报告。批次 ZIP 包含汇总、每项任务的报告和已保存的有序步骤截图;已开启录屏且文件存在时,还会按任务收录录屏片段。失败、跳过和中断任务也会列入。暂停时下载的是当前快照。录屏与 AI 视频分析分别配置,录屏默认关闭;仅开启分析时,录制内容用于本次分析,结束后不保留下载文件。暂停期间不录制其他任务的设备画面。
|
||||||
|
|
||||||
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ MODEL_FIELDS = {
|
|||||||
"timeout_seconds",
|
"timeout_seconds",
|
||||||
"max_retries",
|
"max_retries",
|
||||||
"retry_interval_seconds",
|
"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",
|
"reasoning_effort",
|
||||||
}
|
}
|
||||||
CAPABILITY_FIELDS = {"video_enabled", "outputter_enabled", "step_summarizer_enabled"}
|
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.
|
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, 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. '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,
|
capabilities: video_enabled (recording + analysis), outputter_enabled,
|
||||||
step_summarizer_enabled. reports: formats=['docx','xlsx'] (at least one).
|
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.
|
API keys are never accepted or returned: use the Web UI or .env for credentials.
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 结果、证据与交付
|
## 结果、证据与交付
|
||||||
|
|
||||||
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
|
- 执行状态与业务判定分开:`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。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
|
||||||
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
|
||||||
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
|
||||||
@@ -27,10 +27,10 @@ description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语
|
|||||||
## 配置和恢复边界
|
## 配置和恢复边界
|
||||||
|
|
||||||
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
|
- `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`,不能静默降级或换模型。
|
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
|
||||||
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
|
- 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`;执行中/排队中的批次不能删,删除会清除该批次的任务历史、报告和文件。服务重启后运行/暂停的任务标记中断,不自动重跑。停止可能保留已经产生的业务效果,不声称自动回滚。
|
- 批次暂停只停止后续派发;当前任务等待指导时用 `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 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
|
结果文件在 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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
|
||||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ def isolated_settings(monkeypatch, tmp_path):
|
|||||||
"OPENAI_TIMEOUT_SECONDS",
|
"OPENAI_TIMEOUT_SECONDS",
|
||||||
"OPENAI_MAX_RETRIES",
|
"OPENAI_MAX_RETRIES",
|
||||||
"OPENAI_RETRY_INTERVAL_SECONDS",
|
"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",
|
"OPENAI_REASONING_EFFORT",
|
||||||
"ARTEMIS_VIDEO_ENABLED",
|
"ARTEMIS_VIDEO_ENABLED",
|
||||||
"ARTEMIS_OUTPUTTER_ENABLED",
|
"ARTEMIS_OUTPUTTER_ENABLED",
|
||||||
@@ -43,6 +48,11 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
|
|||||||
api_key="new-test-secret",
|
api_key="new-test-secret",
|
||||||
timeout_seconds=75,
|
timeout_seconds=75,
|
||||||
max_retries=2,
|
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)
|
result = service.save_model_settings(update)
|
||||||
assert result["api_key_configured"]
|
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)
|
saved = dotenv_values(isolated_settings)
|
||||||
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
assert saved["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
assert saved["OPENAI_MODEL"] == "my-model"
|
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 saved["UNRELATED"] == "keep"
|
||||||
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
assert "# user settings" in isolated_settings.read_text(encoding="utf-8")
|
||||||
reloaded = Settings(_env_file=isolated_settings)
|
reloaded = Settings(_env_file=isolated_settings)
|
||||||
assert reloaded.OPENAI_MODEL == "my-model"
|
assert reloaded.OPENAI_MODEL == "my-model"
|
||||||
assert parse_llm_config().operator.model == "my-model"
|
assert parse_llm_config().operator.model == "my-model"
|
||||||
assert parse_llm_config().operator.timeout == 75
|
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("")}))
|
service.save_model_settings(update.model_copy(update={"api_key": SecretStr("")}))
|
||||||
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
|
||||||
|
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ def _make_runner(mock_context, responses, max_turns=None):
|
|||||||
pending = list(responses)
|
pending = list(responses)
|
||||||
|
|
||||||
async def _invoke(llm, tools, messages):
|
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)
|
return pending.pop(0)
|
||||||
|
|
||||||
runner._invoke_model = AsyncMock(side_effect=_invoke)
|
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
|
from artemis.config.settings import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
|
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 (
|
with (
|
||||||
patch("artemis.controllers.unified_controller.get_driver"),
|
patch("artemis.controllers.unified_controller.get_driver"),
|
||||||
|
patch("artemis.agents.flash.runner.get_llm", return_value=Mock()) as get_fallback,
|
||||||
patch(
|
patch(
|
||||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
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"))
|
result = await runner.run(State(initial_goal="Open Settings"))
|
||||||
|
|
||||||
assert result["status"] == "failed"
|
assert result["status"] == "failed"
|
||||||
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
|
assert f"连续 {attempts} 次" in result["explanation"]
|
||||||
assert runner._invoke_model.await_count == attempts
|
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()
|
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
|
# _TurnRecord.result: the turn's execution result in the validator-report shape
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -107,23 +107,56 @@ async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatc
|
|||||||
@pytest.mark.parametrize("limit", [1, 3])
|
@pytest.mark.parametrize("limit", [1, 3])
|
||||||
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
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_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."))
|
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
node, state = operator
|
node, state = operator
|
||||||
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
||||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
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)
|
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||||
node, state = operator
|
node, state = operator
|
||||||
messages = []
|
messages = []
|
||||||
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
||||||
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
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 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)
|
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeyp
|
|||||||
"reasoning_effort": "high",
|
"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
|
@pytest.mark.asyncio
|
||||||
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_api_restart_endpoint():
|
async def test_api_restart_endpoint():
|
||||||
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
"""Verify POST /api/system/restart accepts request and schedules restart."""
|
||||||
|
|||||||
@@ -301,6 +301,32 @@ async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypat
|
|||||||
assert llm_service._FAILED_ENDPOINTS.get() is None
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ def test_non_retryable_categories_get_single_attempt():
|
|||||||
assert retry_policy_for(FailureCategory.RATE_LIMIT).max_attempts > 1
|
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():
|
def test_breaker_only_trips_on_transient_categories():
|
||||||
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
breaker = CircuitBreaker(threshold=2, cooldown_seconds=10.0)
|
||||||
auth = classify_failure(RuntimeError("401 unauthorized"))
|
auth = classify_failure(RuntimeError("401 unauthorized"))
|
||||||
|
|||||||
@@ -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
|
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):
|
def test_timeline_pieces_fill_restart_gaps(tmp_path):
|
||||||
"""Holes between segments (and a window starting in one) become black gaps."""
|
"""Holes between segments (and a window starting in one) become black gaps."""
|
||||||
first = tmp_path / "recording.mkv"
|
first = tmp_path / "recording.mkv"
|
||||||
|
|||||||
Reference in new issue
Block a user