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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user