fix: improve cross-device evidence and exploration lifecycle

Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns.

Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
This commit is contained in:
chenmingxuan committed 2026-10-17 19:43:20 +08:00
1 parent f1bb5d02aa
commit 2328a4df0c
32 files changed
+748 -79

No files matched your search

+14
View File
@@ -283,9 +283,18 @@ async def cleanup_history_endpoint():
@router.post("/api/sessions/{session_id}/delete") @router.post("/api/sessions/{session_id}/delete")
async def delete_session_endpoint(session_id: str): async def delete_session_endpoint(session_id: str):
from artemis.exploration.store import ExplorationConflict, explorations
try: try:
from artemis.data_engine.storage import StorageManager from artemis.data_engine.storage import StorageManager
session_id = str(UUID(session_id))
run = explorations.run(session_id)
if run and run["kind"] == "exploration":
from apps.admin_console.services.exploration_deletion import delete_exploration
deleted = delete_exploration(explorations, run["exploration_id"])
return {"status": "success", "deleted_session_ids": deleted}
storage = StorageManager(DB_PATH, TRACES_PATH) storage = StorageManager(DB_PATH, TRACES_PATH)
storage.delete_session(UUID(session_id)) storage.delete_session(UUID(session_id))
from apps.admin_console.services.automation_store import store from apps.admin_console.services.automation_store import store
@@ -294,6 +303,11 @@ async def delete_session_endpoint(session_id: str):
return { return {
"status": "success", "status": "success",
"message": f"Session {session_id} deleted successfully", "message": f"Session {session_id} deleted successfully",
"deleted_session_ids": [session_id],
} }
except ExplorationConflict as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except (ValueError, KeyError) as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,48 @@
"""Delete an inactive conversation while keeping independently executed reruns."""
import shutil
from uuid import UUID
from apps.admin_console.core.state import state
from apps.admin_console.services import report_export
from artemis.data_engine.storage import StorageManager
from artemis.exploration.store import ExplorationConflict, ExplorationStore
from artemis.runtime.process_probe import pid_is_alive
from artemis.runtime.task_control import read_state
def _check_runs(runs: list[dict]) -> None:
queued = {
item.get("session_id")
for item in state.queue_items
if item.get("status") in {"pending", "queued", "running"}
}
for run in runs:
run_id = run["id"]
if (
run_id in state.active_runs
or run_id in queued
or pid_is_alive(read_state(run_id).get("pid"))
):
raise ExplorationConflict("探索工作进程尚未结束,请停止任务并稍后重试删除")
def delete_exploration(repository: ExplorationStore, exploration_id: str) -> list[str]:
runs = repository.begin_delete(exploration_id, _check_runs)
# Validation sessions and batch copies remain ordinary Flash/Pro history.
deleted = [run["id"] for run in runs if run["kind"] == "exploration"]
storage = StorageManager(repository.database.path, report_export.TRACES_PATH)
with report_export.EXPORT_LOCK:
for run_id in deleted:
safe_id = str(UUID(run_id))
storage.delete_session(UUID(safe_id))
repository.database.delete_history(safe_id)
for root in (
report_export.EXPORT_ROOT,
report_export.EXPORT_ROOT.parent / "mcp_exports",
):
folder = (root / "sessions" / safe_id).resolve()
if folder.is_relative_to(root.resolve()) and folder.is_dir():
shutil.rmtree(folder)
repository.finish_delete(exploration_id)
return deleted
@@ -62,6 +62,7 @@ limitations under the License.
<span>任务队列</span> <span>任务队列</span>
</div> </div>
<app-task-batch-filter /> <app-task-batch-filter />
@if (deleteError()) { <p class="item-error" role="alert">{{ deleteError() }}</p> }
<div class="dropdown-section-label"> <div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span> <span class="material-symbols-outlined icon-bolt">bolt</span>
<span>当前队列 ({{ activeQueue().length }})</span> <span>当前队列 ({{ activeQueue().length }})</span>
@@ -101,7 +102,7 @@ limitations under the License.
<span class="item-time-divider">·</span> <span class="item-time-divider">·</span>
} }
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span> <span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) { @if (!agentService.canDeleteSession(session) && ['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) {
<button <button
type="button" type="button"
class="btn-stop-card" class="btn-stop-card"
@@ -112,7 +113,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span> <span class="material-symbols-outlined">stop_circle</span>
</button> </button>
} }
@if (!['running', 'paused', 'task_paused', 'resuming'].includes(getTaskStatus(session))) { @if (agentService.canDeleteSession(session)) {
<button <button
type="button" type="button"
class="btn-delete-card" class="btn-delete-card"
@@ -294,6 +294,7 @@ export class AgentStreamComponent implements AfterViewInit {
public isTaskDropdownOpen = signal<boolean>(false); public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false); public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false); public isAutomationOpen = signal(false);
public deleteError = signal('');
// Run info popover (opened from the Flash / Pro chip): elapsed time, token // Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with. // usage, executor context share and the Pro tuning the run started with.
@@ -997,12 +998,13 @@ export class AgentStreamComponent implements AfterViewInit {
public deleteTask(sessionId: string, event?: Event): void { public deleteTask(sessionId: string, event?: Event): void {
if (event) event.stopPropagation(); if (event) event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) { if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return; return;
} }
this.deleteError.set('');
this.agentService.deleteSession(sessionId).subscribe({ this.agentService.deleteSession(sessionId).subscribe({
error: (err) => { error: (err) => {
console.error(`删除会话失败:${sessionId}:`, err); this.deleteError.set(err.error?.detail || "删除失败,请稍后重试");
} }
}); });
} }
@@ -59,6 +59,7 @@ limitations under the License.
<!-- Tasks Tab Content --> <!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') { @if (agentService.activeTab() === 'tasks') {
<app-task-batch-filter /> <app-task-batch-filter />
@if (errorMessage()) { <p class="error-message" role="alert">{{ errorMessage() }}</p> }
<!-- Active Queue Section --> <!-- Active Queue Section -->
<div class="queue-section-header"> <div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }}) <span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
@@ -99,7 +100,7 @@ limitations under the License.
<span class="task-time-divider">·</span> <span class="task-time-divider">·</span>
} }
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span> <span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') { @if (!agentService.canDeleteSession(session) && (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused')) {
<button <button
type="button" type="button"
class="btn-stop-card" class="btn-stop-card"
@@ -110,7 +111,7 @@ limitations under the License.
<span class="material-symbols-outlined">stop_circle</span> <span class="material-symbols-outlined">stop_circle</span>
</button> </button>
} }
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') { @if (agentService.canDeleteSession(session)) {
<button <button
type="button" type="button"
class="btn-delete-card" class="btn-delete-card"
@@ -162,7 +162,7 @@ export class ChatInterfaceComponent {
*/ */
public deleteTask(sessionId: string, event: MouseEvent): void { public deleteTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation(); event.stopPropagation();
if (!confirm("确定删除此任务?此操作无法撤销。")) { if (!confirm(this.agentService.deletionPrompt(sessionId))) {
return; return;
} }
this.isSubmitting.set(true); this.isSubmitting.set(true);
@@ -1,4 +1,5 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { of, throwError } from 'rxjs';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
@@ -10,11 +11,46 @@ describe('历史任务检索', () => {
let http: HttpTestingController; let http: HttpTestingController;
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(), TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy()}}]}); {provide: AgentService, useValue: {currentSessionId: signal(null), batchFilter: signal(''), resultRevision: signal(0), selectSession: jasmine.createSpy(), deleteSession: jasmine.createSpy().and.returnValue(of({})), canDeleteSession: AgentService.prototype.canDeleteSession, deletionPrompt: (_id: string, kind: string) => kind === 'exploration' ? '删除探索及全部探索记录,复跑与批次保留' : '删除历史任务'}}]});
http = TestBed.inject(HttpTestingController); http = TestBed.inject(HttpTestingController);
}); });
afterEach(() => http.verify()); afterEach(() => http.verify());
it('已终止探索可删除,确认范围后刷新,取消不发请求', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const row = {session_id:'stopped', status:'cancelled', exploration_kind:'exploration', exploration_status:'paused', start_time:100};
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[row]});
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.delete-history');
expect(button).not.toBeNull();
const confirm = spyOn(window, 'confirm').and.returnValue(false);
button.click();
expect(TestBed.inject(AgentService).deleteSession).not.toHaveBeenCalled();
expect(confirm.calls.mostRecent().args[0]).toContain('复跑与批次保留');
confirm.and.returnValue(true);
button.click();
expect(TestBed.inject(AgentService).deleteSession).toHaveBeenCalledOnceWith('stopped');
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.delete-history')).toBeNull();
fixture.destroy();
});
it('服务端拒绝删除时保留记录并显示具体原因', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items:[
{session_id:'stopped',status:'cancelled',exploration_kind:'exploration',start_time:100}
]});
spyOn(window, 'confirm').and.returnValue(true);
(TestBed.inject(AgentService).deleteSession as jasmine.Spy).and.returnValue(throwError(() => ({error:{detail:'探索正在分析,请稍后删除'}})));
fixture.detectChanges();
fixture.nativeElement.querySelector('.delete-history').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain('探索正在分析');
expect(fixture.nativeElement.querySelector('.delete-history')).not.toBeNull();
fixture.destroy();
});
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => { it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent); const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[ http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
@@ -14,7 +14,7 @@ import { AgentService } from '../../services/agent.service';
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div> <div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>} @if(error()){<p role="alert">{{error()}}</p>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary> @for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(row.exploration_kind !== "exploration"){<button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>} @for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(agent.canDeleteSession(row)){<button class="delete-history" (click)="remove(row.session_id, row.exploration_kind)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
</details>} </details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>} @if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div> <div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
@@ -38,7 +38,7 @@ export class TaskHistoryComponent {
}); });
const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);}); const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
} }
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});} remove(id:string, kind?:string){if(!confirm(this.agent.deletionPrompt(id,kind)))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:e=>this.error.set(e.error?.detail || "删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));} pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;} label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;} duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
@@ -1,5 +1,5 @@
import { signal, computed } from '@angular/core'; import { signal, computed } from '@angular/core';
import { of } from 'rxjs'; import { of, Subject } from 'rxjs';
import { AgentService } from './agent.service'; import { AgentService } from './agent.service';
@@ -531,3 +531,67 @@ describe('Queued exploration identity',()=>{
expect(fields.exploration_id).toBe('conversation');expect(fields.model_info?.architecture).toBe('explore');expect(fields.exploration_latest_run_id).toBe('new'); expect(fields.exploration_id).toBe('conversation');expect(fields.model_info?.architecture).toBe('explore');expect(fields.exploration_latest_run_id).toBe('new');
}); });
}); });
describe('Exploration deletion', () => {
function createService() {
const service = Object.create(AgentService.prototype) as AgentService;
const records: any[] = [
{session_id:'old',status:'completed',exploration_kind:'exploration'},
{session_id:'stopped',status:'cancelled',exploration_status:'paused',exploration_kind:'exploration'},
{session_id:'validation',status:'completed',exploration_kind:'validation'}
];
(service as any).rawSessions = signal(records);
(service as any).activeSessionTracking = new Map(records.map(row => [row.session_id,row]));
(service as any).pendingQueue = signal([]);
service.currentSessionId = signal('stopped');
service.userPinnedSessionId = signal('stopped');
service.isVideoWindowOpen = signal(false);
service.runningSessionId = signal(null);
service.resultRevision = signal(0);
(service as any).invalidateStatusSignatures = jasmine.createSpy();
(service as any).persistSessionsCache = jasmine.createSpy();
service.selectSession = jasmine.createSpy();
(service as any).fetchSessions = jasmine.createSpy();
(service as any).fetchStatus = jasmine.createSpy();
const response = new Subject<any>();
(service as any).http = {post:jasmine.createSpy().and.returnValue(response)};
return {service,response};
}
it('shows delete for ended resumable conversations but not live paused workers', () => {
const {service} = createService();
expect(service.canDeleteSession({status:'cancelled'})).toBeTrue();
expect(service.canDeleteSession({status:'completed'})).toBeTrue();
for(const status of ['running','paused','pending','queued','resuming']) {
expect(service.canDeleteSession({status})).toBeFalse();
}
expect(service.deletionPrompt('stopped')).toContain('全部探索执行记录');
expect(service.deletionPrompt('stopped')).toContain('复跑与批次任务将保留');
});
it('waits for success and removes all deleted runs without removing reruns', () => {
const {service,response} = createService();
service.deleteSession('stopped').subscribe();
expect((service as any).rawSessions().length).toBe(3);
expect(service.selectSession).not.toHaveBeenCalled();
response.next({deleted_session_ids:['old','stopped']});
expect((service as any).rawSessions().map((s:any)=>s.session_id)).toEqual(['validation']);
expect((service as any).activeSessionTracking.has('old')).toBeFalse();
expect(service.selectSession).toHaveBeenCalledWith('',false);
expect(service.userPinnedSessionId()).toBeNull();
expect(service.resultRevision()).toBe(1);
});
it('keeps selection and history when server refuses deletion', () => {
const {service,response} = createService();
const error = jasmine.createSpy();
service.deleteSession('stopped').subscribe({error});
response.error({status:409,error:{detail:'探索仍在执行'}});
expect(error).toHaveBeenCalled();
expect((service as any).rawSessions().length).toBe(3);
expect(service.selectSession).not.toHaveBeenCalled();
expect(service.userPinnedSessionId()).toBe('stopped');
expect(service.resultRevision()).toBe(0);
});
});
@@ -16,7 +16,7 @@
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core'; import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable, tap } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model'; import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model'; import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
@@ -718,45 +718,39 @@ export class AgentService {
/** /**
* Delete an individual session / task * Delete an individual session / task
*/ */
public canDeleteSession(session: Pick<Session, 'status'>): boolean {
return ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(session.status || '');
}
public deletionPrompt(sessionId: string, explorationKind?: string): string {
const kind = explorationKind || this.rawSessions().find(s => s.session_id === sessionId)?.exploration_kind;
return kind === 'exploration'
? '确定删除此探索的对话、用例版本及全部探索执行记录和文件?已生成的复跑与批次任务将保留。此操作无法撤销。'
: '确定删除此历史任务?此操作无法撤销。';
}
public deleteSession(sessionId: string): Observable<any> { public deleteSession(sessionId: string): Observable<any> {
// 1. Optimistically update local session state immediately // Keep the task selected until the server confirms the entire deletion.
this.invalidateStatusSignatures(); return this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).pipe(tap(res => {
this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId)); const deleted = new Set<string>(res.deleted_session_ids || [sessionId]);
this.persistSessionsCache(this.rawSessions()); this.invalidateStatusSignatures();
this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId)); for (const id of deleted) this.activeSessionTracking.delete(id);
this.rawSessions.update(list => list.filter(s => !deleted.has(s.session_id)));
if (this.userPinnedSessionId() === sessionId) { this.pendingQueue.update(list => list.filter(s => !deleted.has(s.session_id)));
this.userPinnedSessionId.set(null); this.persistSessionsCache(this.rawSessions());
} if (deleted.has(this.userPinnedSessionId() || '')) this.userPinnedSessionId.set(null);
if (deleted.has(this.currentSessionId() || '')) {
if (this.currentSessionId() === sessionId) { this.selectSession('', false);
this.selectSession('', false); if (this.isVideoWindowOpen()) this.closeVideoPlayer();
if (this.isVideoWindowOpen()) { const runningId = this.runningSessionId();
this.closeVideoPlayer(); if (runningId && !deleted.has(runningId) && this.agentStatus() === 'running') {
} this.selectSession(runningId, false);
const runningId = this.runningSessionId();
if (runningId && this.agentStatus() === 'running') {
this.selectSession(runningId, false);
}
}
// 2. Send API request
return new Observable((obs) => {
this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).subscribe({
next: (res) => {
this.fetchSessions();
this.fetchStatus();
obs.next(res);
obs.complete();
},
error: (err) => {
console.error(`删除会话失败:${sessionId}:`, err);
this.fetchSessions();
this.fetchStatus();
obs.error(err);
} }
}); }
}); this.resultRevision.update(value => value + 1);
this.fetchSessions();
this.fetchStatus();
}));
} }
/** /**
+4 -2
View File
@@ -42,7 +42,7 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
--- ---
# 4. STAGNATION & LOOP PREVENTION # 4. STAGNATION & LOOP PREVENTION
- **Retrying is not the only option.**: If you perform the same action on the same target for two consecutive turns and the screen or the UI element list shows **no substantial change**, further retries are unlikely to be useful; the probability of the execution tool itself malfunctioning is very low, so you should pause and consider the underlying reason. - **Retrying is not the only option.**: If you perform the same action on the same target for two consecutive turns and the screen or the UI element list shows **no substantial change**, further retries are unlikely to be useful; inspect the actual tool result, target, coordinate space, focus, overlays and scroll container before choosing a different strategy. Do not assume delivery proves an effect or dismiss a tool error.
- **Flexibly Adjust Strategies:** Always prioritize resolving the immediate issue. If you encounter overlays or pop-ups blocking input, or if an element is unresponsive, actively consider alternative ways to complete the task. If an action fails to proceed as expected, systematically consider whether the issue lies with the device itself, the operational path, or a change in state occurring between your actual interaction and the screenshot (Time-Sensitive Tasks). - **Flexibly Adjust Strategies:** Always prioritize resolving the immediate issue. If you encounter overlays or pop-ups blocking input, or if an element is unresponsive, actively consider alternative ways to complete the task. If an action fails to proceed as expected, systematically consider whether the issue lies with the device itself, the operational path, or a change in state occurring between your actual interaction and the screenshot (Time-Sensitive Tasks).
--- ---
@@ -62,4 +62,6 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
## 收尾与跨端验收 ## 收尾与跨端验收
切换到另一端之前,核对当前端的必需验收动作是否已完成并留下证据。提交 report_task_status 前逐项对照用户要求,缺少必要且仍可安全取得的观察时只补该步骤,不重跑已完成部分;无法补齐时明确列出缺项。看到页脚不自动等于滚动已到底,若用户要求验证位置不变,需追加滚动并比较本端前后画面。截图来源、步骤和实际内容共同支持结论,执行成功回执本身不证明验收通过。 首次拆解目标时,把用户明确要求的前置条件、开关状态、检查动作和交付物纳入里程碑;区分待检查、已有证据、受阻,不把计划或已发出动作当成已验证。沿用现有推理和历史记录,仅在状态变化时简短记录“要求 → 来源/步骤 → 实际观察”,不每轮重抄清单或新建规划流程。
切换到另一端前和结束前,逐项核对这些要求(含最新用户指导)。缺少必要且仍可安全取得的证据时只补该步骤,不重跑已有证据的部分;无法补齐时明确列出缺项。看到页脚不自动等于滚动已到底;用户要求边界验证时,需在正确滚动容器内追加滚动并比较本端前后画面。
关键文字、数值或开关状态若在摘要中冲突或不清晰,回查对应步骤原图/控件树,保留来源和不确定性;不按常识、期望或多数摘要猜结论。手机与电脑证据分别归属;仅在同条件、可比较的观察支持时才报告跨端差异。执行成功回执本身不证明验收通过。
+8 -3
View File
@@ -5,7 +5,7 @@
# ROLE & OBJECTIVE # ROLE & OBJECTIVE
You are the Step Summarizer for an Android UI automation agent. Your task is to synthesize the Before/After screenshots and physical action into a concise, high-information-density, strictly objective first-person historical memory. You are the Step Summarizer for a mobile and computer UI automation agent. Your task is to synthesize the Before/After screenshots and physical action into a concise, high-information-density, strictly objective first-person historical memory.
--- ---
@@ -28,11 +28,16 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
--- ---
# EVIDENCE FIDELITY
保留设备/窗口/页面来源及本步编号,不把手机观察归给电脑,或把另一标签页当成目标页面。关键原文(名称、编号、金额、备案文字、开关状态)只转写清晰可读的内容;模糊字符标明“无法辨清”,不凭上下文补全、不改成看起来合理的词。截图、控件树或先前摘要冲突时分别注明读数与来源并标记待核实,不自动选一个当事实。用户意图和操作理由只引导注意力,不证明界面状态。摘要是观察索引,不能替代原始证据作最终裁定。
---
# OPERATOR FOCUS (ATTENTION GUIDANCE) # OPERATOR FOCUS (ATTENTION GUIDANCE)
The input may open with an `--- [0] OPERATOR FOCUS ... ---` block: the operator's own words about this step — the task goal, the active sub-goal, a user instruction, and the operator's reasoning naming the target it chose and the screen change it expected. The action line shows the operator's target as `'...' (self-described)` (its own statement) or as `'...'` (an element observed in the UI tree). Use all of this to direct your attention, never to supply facts: The input may open with an `--- [0] OPERATOR FOCUS ... ---` block: the operator's own words about this step — the task goal, the active sub-goal, a user instruction, and the operator's reasoning naming the target it chose and the screen change it expected. The action line shows the operator's target as `'...' (self-described)` (its own statement) or as `'...'` (an element observed in the UI tree). Use all of this to direct your attention, never to supply facts:
1. **Focused details are mandatory**: Every visible detail that relates to the named target, the stated expectation, the sub-goal or the goal — exact labels, values, amounts, counts, toggle/selection/enabled/loading states, positions — must be transcribed verbatim. Never paraphrase or skip them. If the target control is visible, name its exact label. 1. **Focused details are mandatory**: Every visible detail that relates to the named target, the stated expectation, the sub-goal or the goal — exact labels, values, amounts, counts, toggle/selection/enabled/loading states, positions — must be transcribed verbatim. Never paraphrase clear text; mark unreadable characters explicitly rather than guessing. If the target control is visible, name its exact label.
2. **Absence is a fact**: If something the operator expected or looked for is NOT visible, say so plainly (e.g., "no confirmation dialog is visible", "the 'Pay' button is not on this screen"). Never describe an expected element or change merely because the operator expected it. 2. **Absence is a fact**: If something the operator expected or looked for is NOT visible, say so plainly (e.g., "no confirmation dialog is visible", "the 'Pay' button is not on this screen"). Never describe an expected element or change merely because the operator expected it.
3. **The rest stays complete**: Focus adds emphasis; it never narrows the description. Keep describing the whole screen and the whole transition at full detail. 3. **Relevant context**: Preserve page identity, task-relevant evidence and unexpected changes. Omit repeated navigation chrome and unrelated static content; keep the existing compact paragraph format.
4. **Not evidence**: The focus block is intention, not observation. A self-described target is what the operator believed it aimed at; describe what the red marker actually landed on. 4. **Not evidence**: The focus block is intention, not observation. A self-described target is what the operator believed it aimed at; describe what the red marker actually landed on.
--- ---
@@ -5,7 +5,7 @@
# ROLE & OBJECTIVE # ROLE & OBJECTIVE
You are the Step Summarizer for an Android UI automation agent. Exactly ONE screenshot is available for this step — the decision frame captured when the action was issued. There is NO after-action screenshot. Your task is to describe, strictly objectively and in first person, what THIS screen showed and where the physical action landed. You are the Step Summarizer for a mobile and computer UI automation agent. Exactly ONE screenshot is available for this step — the decision frame captured when the action was issued. There is NO after-action screenshot. Your task is to describe, strictly objectively and in first person, what THIS screen showed and where the physical action landed.
--- ---
@@ -31,11 +31,16 @@ You are the Step Summarizer for an Android UI automation agent. Exactly ONE scre
--- ---
# EVIDENCE FIDELITY
保留设备/窗口/页面来源及本步编号,不把手机观察归给电脑,或把另一标签页当成目标页面。关键原文(名称、编号、金额、备案文字、开关状态)只转写清晰可读的内容;模糊字符标明“无法辨清”,不凭上下文补全、不改成看起来合理的词。截图、控件树或先前摘要冲突时分别注明读数与来源并标记待核实,不自动选一个当事实。用户意图和操作理由只引导注意力,不证明界面状态。摘要是观察索引,不能替代原始证据作最终裁定。
---
# OPERATOR FOCUS (ATTENTION GUIDANCE) # OPERATOR FOCUS (ATTENTION GUIDANCE)
The input may open with an `--- [0] OPERATOR FOCUS ... ---` block: the operator's own words about this step — the task goal, the active sub-goal, a user instruction, and the operator's reasoning naming the target it chose and the screen change it expected. The action line shows the operator's target as `'...' (self-described)` (its own statement) or as `'...'` (an element observed in the UI tree). Use all of this to direct your attention, never to supply facts: The input may open with an `--- [0] OPERATOR FOCUS ... ---` block: the operator's own words about this step — the task goal, the active sub-goal, a user instruction, and the operator's reasoning naming the target it chose and the screen change it expected. The action line shows the operator's target as `'...' (self-described)` (its own statement) or as `'...'` (an element observed in the UI tree). Use all of this to direct your attention, never to supply facts:
1. **Focused details are mandatory**: Every visible detail that relates to the named target, the stated expectation (as it stands on THIS frame only), the sub-goal or the goal — exact labels, values, amounts, counts, toggle/selection/enabled/loading states, positions — must be transcribed verbatim. Never paraphrase or skip them. If the target control is visible, name its exact label. 1. **Focused details are mandatory**: Every visible detail that relates to the named target, the stated expectation (as it stands on THIS frame only), the sub-goal or the goal — exact labels, values, amounts, counts, toggle/selection/enabled/loading states, positions — must be transcribed verbatim. Never paraphrase clear text; mark unreadable characters explicitly rather than guessing. If the target control is visible, name its exact label.
2. **Absence is a fact**: If something the operator expected or looked for is NOT visible, say so plainly (e.g., "no confirmation dialog is visible", "the 'Pay' button is not on this screen"). Never describe an expected element or change merely because the operator expected it. 2. **Absence is a fact**: If something the operator expected or looked for is NOT visible, say so plainly (e.g., "no confirmation dialog is visible", "the 'Pay' button is not on this screen"). Never describe an expected element or change merely because the operator expected it.
3. **The rest stays complete**: Focus adds emphasis; it never narrows the description. Keep describing the whole screen and the whole transition at full detail. 3. **Relevant context**: Preserve page identity, task-relevant evidence and unexpected changes. Omit repeated navigation chrome and unrelated static content; keep the existing compact paragraph format.
4. **Not evidence**: The focus block is intention, not observation. A self-described target is what the operator believed it aimed at; describe what the red marker actually landed on. 4. **Not evidence**: The focus block is intention, not observation. A self-described target is what the operator believed it aimed at; describe what the red marker actually landed on.
--- ---
+14 -2
View File
@@ -254,6 +254,13 @@ class VisualStepSummarizer(StepMemoryService):
else: else:
key = action_key if action_key is not None else step_number key = action_key if action_key is not None else step_number
receipt_outcome = exec_outcome
# Keep the tree in its labelled evidence block, and any distinct
# controller status/errors around it. Deduplicate before clipping.
if post_img_bytes and post_ui_elements_text and post_ui_elements_text.strip():
exec_outcome = exec_outcome.replace(
post_ui_elements_text.strip(), "[UI observation in AFTER ACTION UI ELEMENTS]", 1
)
if action_name.startswith("plug_"): if action_name.startswith("plug_"):
from artemis.config.constants import PLUGIN_SUMMARY_TEXT_CHARS from artemis.config.constants import PLUGIN_SUMMARY_TEXT_CHARS
from artemis.plugins.prompt_budget import bounded_result_text from artemis.plugins.prompt_budget import bounded_result_text
@@ -287,7 +294,7 @@ class VisualStepSummarizer(StepMemoryService):
if unchanged if unchanged
else f"步骤 {step_number}:只读观察 {action_name};未执行写操作。" else f"步骤 {step_number}:只读观察 {action_name};未执行写操作。"
"以下为工具返回摘录,截图仍在本步骤证据中;不代表视觉验收通过。\n" "以下为工具返回摘录,截图仍在本步骤证据中;不代表视觉验收通过。\n"
) + bounded_result_text(exec_outcome, limit=PLUGIN_SUMMARY_TEXT_CHARS) ) + bounded_result_text(receipt_outcome, limit=PLUGIN_SUMMARY_TEXT_CHARS)
# Evidence has already been persisted by the caller. As with a # Evidence has already been persisted by the caller. As with a
# completed visual summary, do not retain every frame in memory. # completed visual summary, do not retain every frame in memory.
payload.update(pre_img_bytes=None, post_img_bytes=None, focus=None) payload.update(pre_img_bytes=None, post_img_bytes=None, focus=None)
@@ -528,8 +535,13 @@ class VisualStepSummarizer(StepMemoryService):
HumanMessage(content=content_blocks), HumanMessage(content=content_blocks),
] ]
from artemis.llm.cache_affinity import cache_affinity_options
response = await asyncio.wait_for( response = await asyncio.wait_for(
self._llm.ainvoke(adapt_responses_messages(messages, self._model_name)), self._llm.ainvoke(
adapt_responses_messages(messages, self._model_name),
**cache_affinity_options(self.ctx, "lens:visual_transition", self._model_name),
),
timeout=self._timeout_seconds, timeout=self._timeout_seconds,
) )
self._meter_lens_call(response) self._meter_lens_call(response)
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"main_template": "# 输出语言要求\n所有自行撰写的自然语言说明必须使用简体中文,包括每一步的操作说明、计划与子任务、检查反馈、诊断结论、历史摘要和最终报告。英文示例仅示范格式,不决定输出语言。\n保留工具名、参数名、JSON 字段名、枚举值、状态码、计划语法标记、模板变量、命令、路径、URL、包名及标识符原文。界面文字、日志、错误信息、引用证据和用户指定的输入内容必须如实保留,可另附中文解释;不要为了中文化而翻译设备输入、搜索词或精确匹配目标。\n遵守各自原有输出结构;结构化结果只将允许自由填写的说明文本写成简体中文。只描述已有证据,不编造操作结果。\n\n每次调用设备操作工具前,用一小段简体中文说明当前观察、即将执行的动作和目的;操作后仅在获得结果或新观察时说明实际变化。不要只在最终答复中使用中文。工具参数中的自由描述文本也使用简体中文,精确引用的界面文字仍保留原文。\n\n# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidirectional; never reverse direction merely to prove completion. If the task declares an earlier exit condition (for example, find one verified match or process N items), that explicit condition may end the traversal without reaching the list boundary.\n 3. **Memory & Plan-Driven Deduplication (Zero-Duplication)**: Systematically log extracted item names, identifiers, or processing states into notes or memory as you progress. After every screen transition, cross-reference visible items against your recorded ledger and strictly interact only with newly surfaced, unrecorded items to eliminate redundant actions on overlapping elements. Proactively maintain subtasks in `task_plan` (e.g., segmenting by batch, category, or alphabetical ranges like `a-d`), tracking exact paths and progress to prevent backtLine truncated "main_template": "# 输出语言要求\n所有自行撰写的自然语言说明必须使用简体中文,包括每一步的操作说明、计划与子任务、检查反馈、诊断结论、历史摘要和最终报告。英文示例仅示范格式,不决定输出语言。\n保留工具名、参数名、JSON 字段名、枚举值、状态码、计划语法标记、模板变量、命令、路径、URL、包名及标识符原文。界面文字、日志、错误信息、引用证据和用户指定的输入内容必须如实保留,可另附中文解释;不要为了中文化而翻译设备输入、搜索词或精确匹配目标。\n遵守各自原有输出结构;结构化结果只将允许自由填写的说明文本写成简体中文。只描述已有证据,不编造操作结果。\n\n每次调用设备操作工具前,用一小段简体中文说明当前观察、即将执行的动作和目的;操作后仅在获得结果或新观察时说明实际变化。不要只在最终答复中使用中文。工具参数中的自由描述文本也使用简体中文,精确引用的界面文字仍保留原文。\n\n# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **要求与证据**:沿用现有任务计划,将用户明确要求的前置条件、开关状态、检查动作和交付物列为必要项,区分待检查、已有证据、受阻。仅在状态变化时简短记下要求、来源/步骤和实际观察,不每轮重抄清单。跨端切换前和结束前核对必要项及最新用户指导,只补尚缺且可安全取得的证据;无法补齐时明确列出缺项。用户希望发生、动作已发出或计划已勾选都不证明验收通过。\n- **证据冲突**:关键文字、数值或开关状态有冲突/不清晰时,回查对应步骤原图或控件树,保留来源和不确定性,不按常识或多数摘要猜测。手机与电脑证据分别归属,仅在同条件观察支持时报告跨端差异。保持已有可靠证据,只回查缺失或矛盾部分。\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading statLine truncated
} }
+1
View File
@@ -81,6 +81,7 @@ async def discuss(repository, session_id, message):
"""Explicit message POST may start a reply; reads never start any work.""" """Explicit message POST may start a reply; reads never start any work."""
key = message["id"] key = message["id"]
with repository._transaction() as connection: with repository._transaction() as connection:
repository._require(connection, session_id)
old = repository._get(connection, "exploration_discussion", key) old = repository._get(connection, "exploration_discussion", key)
if old and old["status"] in {"running", "completed"}: if old and old["status"] in {"running", "completed"}:
return return
+57
View File
@@ -62,6 +62,8 @@ class ExplorationStore:
session = self._get(connection, "exploration", identifier(session_id)) session = self._get(connection, "exploration", identifier(session_id))
if session is None: if session is None:
raise KeyError("探索会话不存在") raise KeyError("探索会话不存在")
if session.get("status") == "deleting":
raise ExplorationConflict("探索正在删除,不能继续操作")
return session return session
def _operation( def _operation(
@@ -75,6 +77,8 @@ class ExplorationStore:
if previous: if previous:
if previous["fingerprint"] != fingerprint: if previous["fingerprint"] != fingerprint:
raise ExplorationConflict("同一 request_id 的内容与已提交请求不同") raise ExplorationConflict("同一 request_id 的内容与已提交请求不同")
if previous.get("deleted"):
raise ExplorationConflict("此请求关联的探索已删除,请重新创建任务")
return previous["result"] return previous["result"]
result = operation() result = operation()
self._put( self._put(
@@ -299,6 +303,59 @@ class ExplorationStore:
).fetchall() ).fetchall()
return [json.loads(row[0]) for row in rows] return [json.loads(row[0]) for row in rows]
def begin_delete(self, session_id: str, check_runs: Callable[[list[dict]], None]) -> list[dict]:
"""Fence new work before deleting files; failed cleanup can be retried."""
with self._transaction() as connection:
session = self._get(connection, "exploration", identifier(session_id))
if session is None:
raise KeyError("探索会话不存在")
if session.get("active_run_id") or session.get("active_demonstration_id"):
raise ExplorationConflict("探索仍有执行或示范任务,请先停止并等待结束后删除")
rows = connection.execute(
"SELECT kind,payload FROM automation_records "
"WHERE json_extract(payload,'$.exploration_id')=?",
(session_id,),
).fetchall()
runs = []
for row in rows:
item = json.loads(row["payload"])
if row["kind"] == "exploration_run":
if item.get("outcome") is None:
raise ExplorationConflict("探索执行尚未结束,请先停止并等待结束后删除")
runs.append(item)
elif row["kind"] in {"exploration_generation", "exploration_discussion"}:
if item.get("status") in {"pending", "running"}:
raise ExplorationConflict("探索正在分析或生成内容,请等待结束后删除")
check_runs(runs)
session["status"] = "deleting"
self._save_session(connection, session)
return runs
def finish_delete(self, session_id: str) -> None:
with self._transaction() as connection:
session = self._get(connection, "exploration", session_id)
if not session or session.get("status") != "deleting":
raise ExplorationConflict("探索尚未准备删除")
connection.execute(
"DELETE FROM automation_records WHERE kind IN "
"('exploration_run','exploration_message','exploration_case',"
"'exploration_demo','exploration_generation','exploration_discussion') "
"AND json_extract(payload,'$.exploration_id')=?",
(session_id,),
)
connection.execute(
"DELETE FROM automation_records WHERE kind='exploration' AND id=?", (session_id,)
)
# Retain only idempotency tombstones, not snapshots of deleted messages/cases.
connection.execute(
"UPDATE automation_records SET payload="
"json_set(json_remove(payload,'$.result'),'$.deleted',json('true')) "
"WHERE kind='exploration_request' AND "
"(json_extract(payload,'$.result.id')=? OR "
"json_extract(payload,'$.result.exploration_id')=?)",
(session_id, session_id),
)
def set_run_state( def set_run_state(
self, self,
run_id: str, run_id: str,
+40
View File
@@ -0,0 +1,40 @@
"""Per-conversation routing hints shared by agent and auxiliary Responses calls."""
from collections.abc import Mapping
import hashlib
import re
from typing import Any
from uuid import UUID
def cache_affinity_options(
ctx: Any, role: str | None, model_name: str | None, options: dict | None = None
) -> dict:
"""Keep body and header routing keys aligned without changing model defaults.
An exploration survives multiple execution sessions. Each model role keeps
its own cache scope so background summaries do not compete with the agent.
Set hints per call: raw models/HTTP clients may be shared by concurrent tasks.
Explicit caller keys and headers take precedence (including underscore aliases).
"""
result = dict(options or {})
engine = getattr(ctx, "data_engine", None)
session = getattr(ctx, "exploration_id", None) or getattr(engine, "current_session_id", None)
if isinstance(session, (str, UUID)) and str(session):
identity = f"{session}:{role or 'model'}:{model_name}"
result.setdefault(
"prompt_cache_key", "artemis:" + hashlib.sha256(identity.encode()).hexdigest()[:40]
)
key = result.get("prompt_cache_key")
if not isinstance(key, str) or not re.fullmatch(r"[-0-9A-Za-z._:]{1,128}", key):
return result
existing = result.get("extra_headers")
if existing is not None and not isinstance(existing, Mapping):
return result
headers = dict(existing or {})
names = {name.lower().replace("_", "-") for name in headers}
for name in ("Thread-Id", "Session-Id"):
if name.lower() not in names:
headers[name] = key
result["extra_headers"] = headers
return result
+21
View File
@@ -4,6 +4,7 @@ import base64
from contextvars import ContextVar from contextvars import ContextVar
from copy import deepcopy from copy import deepcopy
import io import io
import hashlib
import logging import logging
import re import re
import time import time
@@ -208,6 +209,26 @@ class RequestAttempt:
self.publish() self.publish()
async def observe_request_headers(request):
"""Correlate a wire request and record only hashes of routing headers."""
attempt = ACTIVE_ATTEMPT.get()
if attempt is None:
return
request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"])
routing = {}
for name in ("thread-id", "thread_id", "session-id", "session_id"):
value = request.headers.get(name)
if value is not None:
routing[name] = hashlib.sha256(value.encode()).hexdigest()[:16]
attempt.data.update(
request_sent_at=time.time(),
routing_header_fingerprints=routing,
client_request_id_matches=request.headers.get("X-Client-Request-Id")
== attempt.data["request_id"],
)
attempt.publish()
async def observe_response_headers(response): async def observe_response_headers(response):
"""httpx hook: only allowlisted headers, never body/auth/request headers.""" """httpx hook: only allowlisted headers, never body/auth/request headers."""
attempt = ACTIVE_ATTEMPT.get() attempt = ACTIVE_ATTEMPT.get()
+5 -2
View File
@@ -89,7 +89,7 @@ class ModelFactory:
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from openai import DefaultAsyncHttpxClient from openai import DefaultAsyncHttpxClient
from artemis.llm.diagnostics import observe_response_headers from artemis.llm.diagnostics import observe_request_headers, observe_response_headers
api_key = endpoint.api_key or ( api_key = endpoint.api_key or (
settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None
@@ -114,7 +114,10 @@ class ModelFactory:
"store": False, "store": False,
"http_async_client": DefaultAsyncHttpxClient( "http_async_client": DefaultAsyncHttpxClient(
timeout=endpoint.timeout_seconds, timeout=endpoint.timeout_seconds,
event_hooks={"response": [observe_response_headers]}, event_hooks={
"request": [observe_request_headers],
"response": [observe_response_headers],
},
), ),
} }
return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None}) return ChatOpenAI(**{k: v for k, v in kwargs.items() if v is not None})
+9 -1
View File
@@ -504,8 +504,16 @@ class StepCapsuleLens(StepLens):
return parsed return parsed
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str): async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
from artemis.llm.cache_affinity import cache_affinity_options
request_messages = adapt_responses_messages(messages, model_label) request_messages = adapt_responses_messages(messages, model_label)
response = await asyncio.wait_for(llm.ainvoke(request_messages), timeout=90.0) response = await asyncio.wait_for(
llm.ainvoke(
request_messages,
**cache_affinity_options(self._ctx, "lens:step_capsule", model_label),
),
timeout=90.0,
)
# Raw-model bypass metering (gateway-wrapped models meter themselves); # Raw-model bypass metering (gateway-wrapped models meter themselves);
# capsule prompts are small and are kept out of the session meter's # capsule prompts are small and are kept out of the session meter's
# last_prompt_tokens (the compaction base itself is operator-owned on # last_prompt_tokens (the compaction base itself is operator-owned on
+3 -2
View File
@@ -1,5 +1,5 @@
# ROLE # ROLE
You are the Segment Capsule writer for a mobile automation agent's history compression. You receive one contiguous segment of executed steps and produce the two LLM-authored bands of the segment's history chunk: You are the Segment Capsule writer for a mobile and computer automation agent's history compression. You receive one contiguous segment of executed steps and produce the two LLM-authored bands of the segment's history chunk:
- Band ① — Synopsis & effects: what this segment was doing, what was actually done, and what effects/artifacts it left behind, plus structured fields. - Band ① — Synopsis & effects: what this segment was doing, what was actually done, and what effects/artifacts it left behind, plus structured fields.
- Band ② — Compressed step summary: an interval narrative ("Steps xx–xx did ..., Step xx did ...") covering every step of the segment. - Band ② — Compressed step summary: an interval narrative ("Steps xx–xx did ..., Step xx did ...") covering every step of the segment.
@@ -21,7 +21,8 @@ A turn block without a transcript falls back to a reasoning excerpt; treat it th
2. **First-person perspective**: write from the agent's perspective using "I". Never "the agent" / "the operator". 2. **First-person perspective**: write from the agent's perspective using "I". Never "the agent" / "the operator".
3. **Planned iterations are not anomalies**: systematic processing of different candidate items, or periodic polling cycles under a `[Loop]` / `[Loop:continuous]` milestone, is planned progress — record it objectively (e.g. "polling round #2"). Only flag a loop when the exact same target is repeated without progress or states oscillate blindly; then record the pattern and count factually. 3. **Planned iterations are not anomalies**: systematic processing of different candidate items, or periodic polling cycles under a `[Loop]` / `[Loop:continuous]` milestone, is planned progress — record it objectively (e.g. "polling round #2"). Only flag a loop when the exact same target is repeated without progress or states oscillate blindly; then record the pattern and count factually.
4. **Preserve, don't abstract**: be deliberately detailed and stay close to the source wording. Prefer longer output over abstracting facts beyond recognition. Faithfully carry visible text, codes, prices, account names, file names, error banners — and the concrete content of tool results the operator relied on (an explorer's answer, a recalled step, a note's contents, a command's output). 4. **Preserve, don't abstract**: be deliberately detailed and stay close to the source wording. Prefer longer output over abstracting facts beyond recognition. Faithfully carry visible text, codes, prices, account names, file names, error banners — and the concrete content of tool results the operator relied on (an explorer's answer, a recalled step, a note's contents, a command's output).
5. **Fact priority**: user instructions > validator/controller recorded results > tool results returned to the operator (explorer / recall / notes / ADB) > UI/OCR deltas > visual transition summaries > the operator's reasoning. 5. **Requirements are not observations**: user instructions govern what to do, not what happened. Controller receipts establish delivery, not goal achievement. Preserve recorded observations with device/window/page and step attribution; distinguish UI/OCR readings, visual summaries, tool reports and operator hypotheses. Conflicting or unreadable critical text stays unresolved with its competing sources; do not silently choose a spelling or merge different devices' observations into one fact.
6. **Requirement continuity**: preserve outstanding required checks, blockers and evidence links in the existing fields. Carry observed requirement facts in `verified_facts` with source/step; keep missing checks and contradictions in `unresolved`. Planned actions, user expectations and checked plan boxes alone cannot promote an item to verified. Do not repeat the same checklist across all bands.
# BAND ① REQUIREMENTS # BAND ① REQUIREMENTS
Band ① is the segment-level account; band ② below it is the interval-level account. The two bands must not say the same thing twice: ① never re-lists what the intervals of ② already narrate, and it should come out shorter than ② (the `doing` + `did` + `effect` prose together — not counting the structured fields — shorter than the ② intervals together). Band ① is the segment-level account; band ② below it is the interval-level account. The two bands must not say the same thing twice: ① never re-lists what the intervals of ② already narrate, and it should come out shorter than ② (the `doing` + `did` + `effect` prose together — not counting the structured fields — shorter than the ② intervals together).
+7
View File
@@ -139,6 +139,13 @@ class PluginRuntime:
lines.append( lines.append(
"只调用实际提供的工具。电脑插件的像素坐标以其返回的截图为准,不使用 Android 的 0–1000 坐标归一化。每个资源的截图和读数必须分开核对;只有文字观察时不能猜像素坐标。" "只调用实际提供的工具。电脑插件的像素坐标以其返回的截图为准,不使用 Android 的 0–1000 坐标归一化。每个资源的截图和读数必须分开核对;只有文字观察时不能猜像素坐标。"
) )
lines.append(
"电脑操作及保存截图前,用已有观察核对应用、窗口和页面身份;浏览器结合地址栏、"
"选中标签与页面内容,不能仅凭窗口还存在就认定页面正确。身份不符时先定位目标。"
"动作返回的新截图可能改变尺寸与编号,坐标和元素标识只取自同一张最新图;"
"不沿用旧图坐标、不自行叠加 DPI 缩放。无位移时先排查回执、焦点、遮挡和滚动容器,"
"不连续重试同一点,也不把无效滚动当作已经到底。"
)
return "\n".join(lines) return "\n".join(lines)
async def load(self, plugin_id: str) -> PluginCallResult: async def load(self, plugin_id: str) -> PluginCallResult:
+3 -12
View File
@@ -23,14 +23,13 @@ from collections.abc import Awaitable, Callable, Coroutine
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from contextlib import aclosing from contextlib import aclosing
import functools import functools
import hashlib
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
import sys import sys
import time import time
from typing import Any, Literal, TypeVar, overload from typing import Any, Literal, TypeVar, overload
from uuid import UUID, uuid4 from uuid import uuid4
from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
@@ -48,6 +47,7 @@ from artemis.config import (
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler
from artemis.llm.diagnostics import ACTIVE_ATTEMPT, RequestAttempt, error_text, input_images from artemis.llm.diagnostics import ACTIVE_ATTEMPT, RequestAttempt, error_text, input_images
from artemis.llm.cache_affinity import cache_affinity_options
from artemis.llm.reliability import ( from artemis.llm.reliability import (
CircuitBreaker, CircuitBreaker,
FailureCategory, FailureCategory,
@@ -637,16 +637,7 @@ class RobustChatModelWrapper:
def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]: def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]:
model_name = self.endpoint.model_name if self.endpoint else None model_name = self.endpoint.model_name if self.endpoint else None
engine = getattr(self.ctx, "data_engine", None) kwargs = cache_affinity_options(self.ctx, self.role, model_name, kwargs)
session = getattr(self.ctx, "exploration_id", None) or getattr(
engine, "current_session_id", None
)
if isinstance(session, (str, UUID)) and str(session):
identity = f"{session}:{self.role or 'model'}:{model_name}"
kwargs = {
"prompt_cache_key": "artemis:" + hashlib.sha256(identity.encode()).hexdigest()[:40],
**kwargs,
}
if ( if (
args args
and isinstance(args[0], (list, tuple)) and isinstance(args[0], (list, tuple))
+8
View File
@@ -79,6 +79,8 @@ Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中
2.0.0 插件协议与可运行示例见 [开发规则](plugin-development.md)。单任务和批次全部资料包含各来源截图/录像、插件附件及包内证据索引。 2.0.0 插件协议与可运行示例见 [开发规则](plugin-development.md)。单任务和批次全部资料包含各来源截图/录像、插件附件及包内证据索引。
Flash、Pro 与探索沿用现有执行上下文跟踪用户明确要求的前置条件、检查项和交付物,跨端及收尾时只补缺失证据。电脑操作或留存截图前核对窗口与页面身份;坐标以同一张最新插件截图及其编号为准,不使用手机归一化坐标或自行叠加 DPI。视觉与历史摘要保留来源、关键原文和待核实项,模糊文字或冲突读数不能直接成为已验证事实;需要时回查原图/控件树。本规则不新增规划、验收或摘要模型调用,不改变现有思考强度和输出预算。
通过 `mobile_config(section="capabilities")` 可读取 `recording` 参数;用户授权后可保存全局默认。单任务用 `runtime_overrides={"recording":{"codec":"h264","computer_scope":"window","max_long_edge":1280,"fps":15}}`,批次在 `task_config.recording` 提供同样对象。JSON 配置的 `agent.recording` 覆盖环境默认;不要把录像参数当作模型截图参数。录屏开关继续独立且默认关闭。 通过 `mobile_config(section="capabilities")` 可读取 `recording` 参数;用户授权后可保存全局默认。单任务用 `runtime_overrides={"recording":{"codec":"h264","computer_scope":"window","max_long_edge":1280,"fps":15}}`,批次在 `task_config.recording` 提供同样对象。JSON 配置的 `agent.recording` 覆盖环境默认;不要把录像参数当作模型截图参数。录屏开关继续独立且默认关闭。
运行信息的上下文参照上限固定为 300,000 tokens,前后端使用相同口径。历史压缩预算默认 80,000,JSON 的 `agent.memory.transcript.context_budget_tokens` 可调但不能超过 300,000;这是压缩触发预算,不是单次生成的输出 token 数,也不是整项任务累计 token 的限额。 运行信息的上下文参照上限固定为 300,000 tokens,前后端使用相同口径。历史压缩预算默认 80,000,JSON 的 `agent.memory.transcript.context_budget_tokens` 可调但不能超过 300,000;这是压缩触发预算,不是单次生成的输出 token 数,也不是整项任务累计 token 的限额。
@@ -98,3 +100,9 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
- `cold_start`:该角色/端点在当前进程内没有比较基准;重启后首次请求会重新建立基准。 - `cold_start`:该角色/端点在当前进程内没有比较基准;重启后首次请求会重新建立基准。
以上为适配后消息的本地指纹比较,不是网关最终序列化后的 token 前缀,也不是命中率。诊断仅持有哈希,不额外保存提示词、图片正文或凭据。实际缓存率按上游返回的 `cached_tokens / input_tokens` 加权统计,usage 缺失不能当作零命中。首次请求、阶段压缩、模型/工具切换可能降低缓存率;网关的路由、缓存保留时间和实现也会影响结果。该实现使用完整多轮消息,不依赖网关支持 `previous_response_id`,也不承诺固定命中率。 以上为适配后消息的本地指纹比较,不是网关最终序列化后的 token 前缀,也不是命中率。诊断仅持有哈希,不额外保存提示词、图片正文或凭据。实际缓存率按上游返回的 `cached_tokens / input_tokens` 加权统计,usage 缺失不能当作零命中。首次请求、阶段压缩、模型/工具切换可能降低缓存率;网关的路由、缓存保留时间和实现也会影响结果。该实现使用完整多轮消息,不依赖网关支持 `previous_response_id`,也不承诺固定命中率。
执行模型、视觉摘要和历史摘要的 Responses 请求均按“探索会话(普通任务用任务 ID)+角色+模型”生成稳定的匿名 `prompt_cache_key`,并在 `Thread-Id`、`Session-Id` 请求头中发送相同值。标识符合 `^[-0-9A-Za-z._:]{1,128}$`;暂停继续时保持稳定,不同任务或角色隔离。网关可从头部或 JSON 字段提取路由键,不依赖特定 User-Agent,也不会伪装为 Codex。标识在每次调用时设置,不写入共享 HTTP 客户端,避免并发任务互相覆盖。显式传入的缓存键及会话请求头优先。
有请求诊断的调用同时发送 `X-Client-Request-Id`,默认与 trace 内的 `request_id` 一致,便于对照 API 日志。`request_sent_at` 表示实际发送时间;`routing_header_fingerprints` 只记录发送的会话头的哈希,`client_request_id_matches` 表示关联 ID 是否一致。不记录认证头或其他请求头正文。稳定标识提供路由亲和条件,实际命中仍取决于前缀和上游缓存。
辅助摘要继续独立处理各自片段,避免为了提高命中比例累积不相关的历史。视觉摘要中完全重复的插件执行结果与控件树只发送一份;独立错误、状态、不同观察及原始 trace 均保留。
+2
View File
@@ -46,6 +46,8 @@
队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。 队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。
已结束或已终止的探索可在任务队列和历史中删除,即使它仍显示“已暂停 · 可继续”。删除前会确认范围:清除该探索的对话、用例版本、所有探索执行记录及文件,已生成的独立复跑和批次任务保留。仍有执行、示范或用例分析在进行时会拒绝删除;请先停止或等待结束。
## AI 使用 ## AI 使用
通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。 通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。
@@ -93,6 +93,57 @@ async def test_summary_rules_and_goal_prefix_stay_identical_across_steps(mock_co
assert "Step 2 Physical Action" in str(blocks2) assert "Step 2 Physical Action" in str(blocks2)
@pytest.mark.asyncio
@pytest.mark.parametrize("duplicate", [True, False])
async def test_summary_keeps_ui_once_without_losing_controller_errors(mock_context, duplicate):
summarizer = VisualStepSummarizer(mock_context)
summarizer._llm = Mock(
ainvoke=AsyncMock(return_value=AIMessage(content="我观察到当前设置,状态变化仍待确认。"))
)
ui = 'Window Brave\n[1] Text "未保存"\n[2] Button "重试"'
outcome = "控制器错误: input rejected\n" + (ui if duplicate else "different earlier tree")
outcome += "\n恢复状态: unknown"
summarizer.dispatch(
step_number=1,
action_name="plug_click",
action_args={},
pre_img_bytes=b"before",
post_img_bytes=b"after",
exec_outcome=outcome,
post_ui_elements_text=ui,
observation_settled=False,
)
await summarizer.flush()
_, blocks = _user_role_prompt(summarizer._llm.ainvoke.call_args.args[0])
text = "\n".join(b.get("text", "") for b in blocks)
assert text.count(ui) == 1
assert "控制器错误: input rejected" in text and "恢复状态: unknown" in text
assert "screen still changing" in text
assert "AFTER ACTION UI ELEMENTS" in text
if not duplicate:
assert "different earlier tree" in text
@pytest.mark.asyncio
async def test_receipt_summary_retains_tree_even_when_duplicate_is_removed_for_model(mock_context):
summarizer = VisualStepSummarizer(mock_context)
summarizer._llm = Mock(ainvoke=AsyncMock())
ui = 'Window Brave\n[1] Text "未保存"'
summarizer.dispatch(
step_number=1,
action_name="plug_get_window_state",
action_args={},
pre_img_bytes=b"same",
post_img_bytes=b"same",
exec_outcome="来源: PC\n" + ui,
post_ui_elements_text=ui,
receipt_only=True,
)
await summarizer.flush()
summarizer._llm.ainvoke.assert_not_awaited()
assert ui in summarizer.get_summary(1)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summarizer_dispatch_and_caching(mock_context): async def test_summarizer_dispatch_and_caching(mock_context):
"""Verify non-blocking dispatch and summary generation.""" """Verify non-blocking dispatch and summary generation."""
@@ -54,8 +54,8 @@ from artemis.memory.transcript import (
# SHA-256 snapshots of the legacy system message with the fixed inputs below. # SHA-256 snapshots of the legacy system message with the fixed inputs below.
# Update these when an intentional template change alters the rendered prompt. # Update these when an intentional template change alters the rendered prompt.
GOLDEN_EMPTY_PLAN = "d715128625911aec46c83138e15aabfa967b385ab4c878509fe36ab4304fae2a" GOLDEN_EMPTY_PLAN = "58bf034527b2d782f7746d8e379cb0decc44a05dfa18cb73153d49cd8265002b"
GOLDEN_SENTINEL_PLAN = "e2cf4ede75ffd9e283159f70dcd5c94a169c18e9f6dd4d1e7aacf460e956eaf3" GOLDEN_SENTINEL_PLAN = "b328052390a0a815c096b8ec4c5286421be487e7b6cd3bec55d6bc62a1f2518f"
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
+55
View File
@@ -0,0 +1,55 @@
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from uuid import uuid4
import re
from artemis.llm.cache_affinity import cache_affinity_options
def context(session, exploration=None):
return SimpleNamespace(
data_engine=SimpleNamespace(current_session_id=session), exploration_id=exploration
)
def test_routing_identity_survives_resume_but_isolates_tasks_roles_and_models():
ctx = context(uuid4(), "exploration")
first = cache_affinity_options(ctx, "flash", "gpt-6-luna")
ctx.data_engine.current_session_id = uuid4()
assert first == cache_affinity_options(ctx, "flash", "gpt-6-luna")
key = first["prompt_cache_key"]
assert re.fullmatch(r"[-0-9A-Za-z._:]{1,128}", key)
assert "exploration" not in key
assert first["extra_headers"] == {"Thread-Id": key, "Session-Id": key}
for role, model in [("checker", "gpt-6-luna"), ("flash", "gpt-6.1-sol")]:
assert cache_affinity_options(ctx, role, model)["prompt_cache_key"] != key
assert (
cache_affinity_options(context("another"), "flash", "gpt-6-luna")["prompt_cache_key"] != key
)
def test_explicit_options_are_preserved_without_mutating_headers_or_adding_aliases():
headers = {"thread_ID": "explicit-thread", "session-id": "explicit-session", "X-Test": "ok"}
options = {"prompt_cache_key": "explicit.key", "extra_headers": headers, "temperature": 0}
actual = cache_affinity_options(context("task"), "flash", "test", options)
assert actual == options and actual is not options
assert actual["extra_headers"] is not headers
assert options["extra_headers"] == headers
assert cache_affinity_options(None, "flash", "test") == {}
for key in (None, "", "a" * 129, "unsafe\r\nheader"):
assert cache_affinity_options(
context("task"), "flash", "test", {"prompt_cache_key": key}
) == {"prompt_cache_key": key}
def test_concurrent_calls_do_not_share_or_mutate_task_headers():
shared = {"extra_headers": {"X-Test": "shared"}}
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(
pool.map(
lambda task: cache_affinity_options(context(task), "flash", "test", shared),
["task-a", "task-b"],
)
)
assert results[0]["extra_headers"]["Thread-Id"] != results[1]["extra_headers"]["Thread-Id"]
assert shared == {"extra_headers": {"X-Test": "shared"}}
+166
View File
@@ -0,0 +1,166 @@
import json
from types import SimpleNamespace
from uuid import UUID
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from apps.admin_console.routers import sessions
from apps.admin_console.services import exploration_deletion as deletion
from apps.admin_console.services import report_export
from apps.admin_console.services.automation_store import AutomationStore
from artemis.config.exploration import ExplorationConfig
from artemis.data_engine.storage import StorageManager
from artemis.exploration import store as store_module
from artemis.exploration.models import CaseDraft
from artemis.exploration.store import ExplorationConflict, ExplorationStore
@pytest.fixture
def setup(tmp_path, monkeypatch):
db = tmp_path / "data.db"
traces = tmp_path / "traces"
storage = StorageManager(db, traces)
repository = ExplorationStore(AutomationStore(db))
monkeypatch.setattr(store_module, "explorations", repository)
monkeypatch.setattr(report_export, "TRACES_PATH", traces)
monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports")
monkeypatch.setattr(deletion, "state", SimpleNamespace(queue_items=[], active_runs={}))
monkeypatch.setattr(deletion, "read_state", lambda _: {})
app = FastAPI()
app.include_router(sessions.router)
session = repository.create("探索页面", ExplorationConfig(), {}, "create")
return repository, session["id"], storage, TestClient(app)
def make_run(repository, sid, request, *, kind="exploration", finished=True):
run = repository.reserve_run(
sid,
request,
kind=kind,
goal="观察",
task_config={},
case_version=1 if kind == "validation" else None,
)
if finished:
repository.finish_run(run["id"], {"status": "cancelled"})
return run["id"]
def test_delete_conversation_removes_all_exploration_runs_but_keeps_reruns(setup):
repository, sid, storage, client = setup
first = make_run(repository, sid, "first")
second = make_run(repository, sid, "continue")
repository.save_case(sid, CaseDraft(name="可复用用例", goal="检查页面"), 0, "case")
validation = make_run(repository, sid, "validate", kind="validation")
unrelated = repository.create("另一探索", ExplorationConfig(), {}, "other")["id"]
directories = []
for run_id in (first, second, validation):
repository.database.save_job({"session_id": run_id, "status": "cancelled", "position": 1})
with repository.database.connection() as connection:
connection.execute(
"INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info) VALUES (?, ?, 1, ?, '{}')",
(run_id, "观察", "cancelled"),
)
connection.commit()
for root in (storage.base_trace_dir, report_export.EXPORT_ROOT / "sessions"):
folder = root / run_id
folder.mkdir(parents=True)
(folder / "evidence.txt").write_text("saved", encoding="utf-8")
if run_id != validation:
directories.append(folder)
with repository._transaction() as connection:
session = repository._require(connection, sid)
session["status"] = "paused" # Resumable conversation, no running process.
repository._save_session(connection, session)
response = client.post(f"/api/sessions/{first}/delete")
assert response.status_code == 200, response.text
assert set(response.json()["deleted_session_ids"]) == {first, second}
assert not any(folder.exists() for folder in directories)
assert (storage.base_trace_dir / validation / "evidence.txt").exists()
assert repository.database.job(validation) is not None
assert repository.database.job(first) is None
assert storage.get_session(UUID(validation)) is not None
assert storage.get_session(UUID(first)) is None
assert repository.get(unrelated)["goal"] == "另一探索"
assert repository.run(validation) is None # Detach it from the deleted conversation.
deleted_requests = [
record
for record in repository.database.records("exploration_request")
if record.get("deleted")
]
assert len(deleted_requests) >= 5
assert all("result" not in record for record in deleted_requests)
with repository.database.connection() as connection:
assert not connection.execute(
"SELECT 1 FROM automation_records WHERE kind != 'exploration_request' "
"AND json_extract(payload,'$.exploration_id')=?",
(sid,),
).fetchone()
with pytest.raises(KeyError):
repository.get(sid)
with pytest.raises(ExplorationConflict, match="已删除"):
repository.create("探索页面", ExplorationConfig(), {}, "create")
@pytest.mark.parametrize("state", ["running", "paused", "waiting_user", "demonstrating"])
def test_live_execution_cannot_be_deleted_even_through_an_old_run(setup, state):
repository, sid, _, client = setup
first = make_run(repository, sid, "first")
active = make_run(repository, sid, "continue", finished=False)
repository.set_run_state(active, state)
response = client.post(f"/api/sessions/{first}/delete")
assert response.status_code == 409
assert repository.get(sid)["active_run_id"] == active
assert repository.run(first) is not None
@pytest.mark.parametrize("kind", ["exploration_generation", "exploration_discussion"])
def test_editorial_work_prevents_deletion(setup, kind):
repository, sid, _, client = setup
run_id = make_run(repository, sid, "first")
repository.database.put(kind, "job", {"exploration_id": sid, "status": "running"})
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409
assert repository.get(sid)["status"] == "cancelled"
def test_worker_still_exiting_prevents_deletion(setup, monkeypatch):
repository, sid, _, client = setup
run_id = make_run(repository, sid, "first")
monkeypatch.setattr(
deletion, "state", SimpleNamespace(queue_items=[], active_runs={run_id: {}})
)
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409
assert repository.run(run_id) is not None
def test_cleanup_failure_is_retryable_and_blocks_new_work(setup, monkeypatch):
repository, sid, storage, client = setup
run_id = make_run(repository, sid, "first")
original = StorageManager.delete_session
def fail(*args):
raise OSError("file busy")
monkeypatch.setattr(StorageManager, "delete_session", fail)
response = client.post(f"/api/sessions/{run_id}/delete")
assert response.status_code == 500
with pytest.raises(ExplorationConflict, match="正在删除"):
make_run(repository, sid, "restart")
monkeypatch.setattr(StorageManager, "delete_session", original)
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200
assert not repository.list()
@pytest.mark.asyncio
async def test_delayed_discussion_cannot_recreate_deleted_metadata(setup):
from artemis.exploration.editorial import discuss
repository, sid, _, client = setup
run_id = make_run(repository, sid, "first")
message = repository.add_message(sid, "不再继续", "message")
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200
with pytest.raises(KeyError):
await discuss(repository, sid, message)
assert not repository.database.records("exploration_discussion")
+43
View File
@@ -355,3 +355,46 @@ async def test_cache_key_is_stable_for_exploration_but_separates_roles(
assert payloads[1]["prompt_cache_key"] != payloads[2]["prompt_cache_key"] assert payloads[1]["prompt_cache_key"] != payloads[2]["prompt_cache_key"]
assert "exploration-1" not in payloads[0]["prompt_cache_key"] assert "exploration-1" not in payloads[0]["prompt_cache_key"]
assert payloads[1]["input"][:1] == payloads[0]["input"] assert payloads[1]["input"][:1] == payloads[0]["input"]
for request, payload in zip(requests, payloads):
assert request.headers["thread-id"] == payload["prompt_cache_key"]
assert request.headers["session-id"] == payload["prompt_cache_key"]
assert request.headers["x-client-request-id"]
assert "extra_headers" not in payload
assert requests[0].headers["x-client-request-id"] != requests[1].headers["x-client-request-id"]
@pytest.mark.asyncio
async def test_auxiliary_summary_calls_send_role_scoped_affinity_on_responses(transport_model):
from types import SimpleNamespace
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.memory.chunking import StepCapsuleLens
model, requests, outputs = transport_model
outputs.extend(
[text_output("我看到页面显示当前设置,尚未确认其他状态变化。") for _ in range(4)]
)
ctx = SimpleNamespace(data_engine=SimpleNamespace(current_session_id="summary-task"))
lens = StepCapsuleLens(model_name="test-model", llm=model, ctx=ctx)
for text in ("first chunk", "next chunk"):
await lens._invoke(model, [HumanMessage(content=text)], "test-model")
visual = VisualStepSummarizer(ctx, model_name="test-model")
visual._llm = model
for number in (1, 2):
visual._step_inputs[number] = {
"step_number": number,
"action_name": "press_key",
"action_args": {},
"pre_img_bytes": None,
"post_img_bytes": None,
"exec_outcome": "dispatched",
}
assert await visual._attempt(number)
keys = []
for request in requests:
payload = json.loads(request.content)
assert request.url.path == "/v1/responses"
key = payload["prompt_cache_key"]
assert request.headers["thread-id"] == request.headers["session-id"] == key
keys.append(key)
assert len(keys) == 4 and keys[0] == keys[1] and keys[2] == keys[3] and keys[0] != keys[2]
+31
View File
@@ -13,6 +13,7 @@ from artemis.llm.diagnostics import (
RequestAttempt, RequestAttempt,
error_text, error_text,
input_images, input_images,
observe_request_headers,
observe_response_headers, observe_response_headers,
) )
from artemis.llm.router import ModelEndpoint from artemis.llm.router import ModelEndpoint
@@ -34,6 +35,36 @@ class Engine:
pass pass
@pytest.mark.asyncio
async def test_request_hook_correlates_api_calls_without_logging_header_values():
engine = Engine()
attempt = RequestAttempt(
engine, request_id="local-request", attempt=1, timeout=300, max_retries=1, metadata={}
)
token = ACTIVE_ATTEMPT.set(attempt)
try:
request = httpx.Request(
"POST",
"https://example.test/v1/responses",
headers={
"Thread-Id": "private-thread",
"Session-Id": "private-session",
"Authorization": "Bearer private-key",
},
)
await observe_request_headers(request)
assert request.headers["x-client-request-id"] == "local-request"
assert attempt.data["client_request_id_matches"] is True
assert len(attempt.data["routing_header_fingerprints"]) == 2
assert "private" not in json.dumps(engine.records, default=str)
request.headers["X-Client-Request-Id"] = "caller-id"
await observe_request_headers(request)
assert request.headers["x-client-request-id"] == "caller-id"
assert attempt.data["client_request_id_matches"] is False
finally:
ACTIVE_ATTEMPT.reset(token)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatch): async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatch):
engine = Engine() engine = Engine()