diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index d53e4be..b4c4a3e 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -283,9 +283,18 @@ async def cleanup_history_endpoint(): @router.post("/api/sessions/{session_id}/delete") async def delete_session_endpoint(session_id: str): + from artemis.exploration.store import ExplorationConflict, explorations + try: 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.delete_session(UUID(session_id)) from apps.admin_console.services.automation_store import store @@ -294,6 +303,11 @@ async def delete_session_endpoint(session_id: str): return { "status": "success", "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: raise HTTPException(status_code=500, detail=str(e)) diff --git a/apps/admin_console/services/exploration_deletion.py b/apps/admin_console/services/exploration_deletion.py new file mode 100644 index 0000000..e8c74fc --- /dev/null +++ b/apps/admin_console/services/exploration_deletion.py @@ -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 diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index 48f4528..6e0a486 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -62,6 +62,7 @@ limitations under the License. 任务队列 + @if (deleteError()) { } } + @for(row of group.rows; track row.session_id){
@if(agent.canDeleteSession(row)){}
} } @if(!loading() && !total()){

没有符合条件的历史任务。

}
{{page}} / {{pages()}}
@@ -38,7 +38,7 @@ export class TaskHistoryComponent { }); 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));} label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record)[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}秒`;} diff --git a/apps/showcase_ui/src/app/services/agent.service.spec.ts b/apps/showcase_ui/src/app/services/agent.service.spec.ts index c887160..cc2a5a7 100644 --- a/apps/showcase_ui/src/app/services/agent.service.spec.ts +++ b/apps/showcase_ui/src/app/services/agent.service.spec.ts @@ -1,5 +1,5 @@ import { signal, computed } from '@angular/core'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; 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'); }); }); + + +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(); + (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); + }); +}); diff --git a/apps/showcase_ui/src/app/services/agent.service.ts b/apps/showcase_ui/src/app/services/agent.service.ts index 5a76b9e..fffaef3 100644 --- a/apps/showcase_ui/src/app/services/agent.service.ts +++ b/apps/showcase_ui/src/app/services/agent.service.ts @@ -16,7 +16,7 @@ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core'; 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 { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model'; @@ -718,45 +718,39 @@ export class AgentService { /** * Delete an individual session / task */ + public canDeleteSession(session: Pick): 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 { - // 1. Optimistically update local session state immediately - this.invalidateStatusSignatures(); - this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId)); - this.persistSessionsCache(this.rawSessions()); - this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId)); - - if (this.userPinnedSessionId() === sessionId) { - this.userPinnedSessionId.set(null); - } - - if (this.currentSessionId() === sessionId) { - this.selectSession('', false); - if (this.isVideoWindowOpen()) { - this.closeVideoPlayer(); - } - const runningId = this.runningSessionId(); - if (runningId && this.agentStatus() === 'running') { - this.selectSession(runningId, false); - } - } - - // 2. Send API request - return new Observable((obs) => { - this.http.post(`/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); + // Keep the task selected until the server confirms the entire deletion. + return this.http.post(`/api/sessions/${sessionId}/delete`, {}).pipe(tap(res => { + const deleted = new Set(res.deleted_session_ids || [sessionId]); + this.invalidateStatusSignatures(); + for (const id of deleted) this.activeSessionTracking.delete(id); + this.rawSessions.update(list => list.filter(s => !deleted.has(s.session_id))); + this.pendingQueue.update(list => list.filter(s => !deleted.has(s.session_id))); + this.persistSessionsCache(this.rawSessions()); + if (deleted.has(this.userPinnedSessionId() || '')) this.userPinnedSessionId.set(null); + if (deleted.has(this.currentSessionId() || '')) { + this.selectSession('', false); + if (this.isVideoWindowOpen()) this.closeVideoPlayer(); + const runningId = this.runningSessionId(); + if (runningId && !deleted.has(runningId) && this.agentStatus() === 'running') { + this.selectSession(runningId, false); } - }); - }); + } + this.resultRevision.update(value => value + 1); + this.fetchSessions(); + this.fetchStatus(); + })); } /** diff --git a/artemis/agents/flash/flash_runner.md b/artemis/agents/flash/flash_runner.md index 91d38c0..13a59c7 100644 --- a/artemis/agents/flash/flash_runner.md +++ b/artemis/agents/flash/flash_runner.md @@ -42,7 +42,7 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi --- # 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). --- @@ -62,4 +62,6 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi ## 收尾与跨端验收 -切换到另一端之前,核对当前端的必需验收动作是否已完成并留下证据。提交 report_task_status 前逐项对照用户要求,缺少必要且仍可安全取得的观察时只补该步骤,不重跑已完成部分;无法补齐时明确列出缺项。看到页脚不自动等于滚动已到底,若用户要求验证位置不变,需追加滚动并比较本端前后画面。截图来源、步骤和实际内容共同支持结论,执行成功回执本身不证明验收通过。 +首次拆解目标时,把用户明确要求的前置条件、开关状态、检查动作和交付物纳入里程碑;区分待检查、已有证据、受阻,不把计划或已发出动作当成已验证。沿用现有推理和历史记录,仅在状态变化时简短记录“要求 → 来源/步骤 → 实际观察”,不每轮重抄清单或新建规划流程。 +切换到另一端前和结束前,逐项核对这些要求(含最新用户指导)。缺少必要且仍可安全取得的证据时只补该步骤,不重跑已有证据的部分;无法补齐时明确列出缺项。看到页脚不自动等于滚动已到底;用户要求边界验证时,需在正确滚动容器内追加滚动并比较本端前后画面。 +关键文字、数值或开关状态若在摘要中冲突或不清晰,回查对应步骤原图/控件树,保留来源和不确定性;不按常识、期望或多数摘要猜结论。手机与电脑证据分别归属;仅在同条件、可比较的观察支持时才报告跨端差异。执行成功回执本身不证明验收通过。 diff --git a/artemis/agents/flash/flash_summarizer.md b/artemis/agents/flash/flash_summarizer.md index 865a8da..ca0afab 100644 --- a/artemis/agents/flash/flash_summarizer.md +++ b/artemis/agents/flash/flash_summarizer.md @@ -5,7 +5,7 @@ # 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) 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. -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. --- diff --git a/artemis/agents/flash/flash_summarizer_single.md b/artemis/agents/flash/flash_summarizer_single.md index 700eb4b..8f2196f 100644 --- a/artemis/agents/flash/flash_summarizer_single.md +++ b/artemis/agents/flash/flash_summarizer_single.md @@ -5,7 +5,7 @@ # 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) 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. -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. --- diff --git a/artemis/agents/flash/summarizer.py b/artemis/agents/flash/summarizer.py index 55660fc..4c62fde 100644 --- a/artemis/agents/flash/summarizer.py +++ b/artemis/agents/flash/summarizer.py @@ -254,6 +254,13 @@ class VisualStepSummarizer(StepMemoryService): else: 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_"): from artemis.config.constants import PLUGIN_SUMMARY_TEXT_CHARS from artemis.plugins.prompt_budget import bounded_result_text @@ -287,7 +294,7 @@ class VisualStepSummarizer(StepMemoryService): if unchanged else f"步骤 {step_number}:只读观察 {action_name};未执行写操作。" "以下为工具返回摘录,截图仍在本步骤证据中;不代表视觉验收通过。\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 # completed visual summary, do not retain every frame in memory. payload.update(pre_img_bytes=None, post_img_bytes=None, focus=None) @@ -528,8 +535,13 @@ class VisualStepSummarizer(StepMemoryService): HumanMessage(content=content_blocks), ] + from artemis.llm.cache_affinity import cache_affinity_options + 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, ) self._meter_lens_call(response) diff --git a/artemis/agents/operator/operator.json b/artemis/agents/operator/operator.json index 4dc049b..e6f8d06 100644 --- a/artemis/agents/operator/operator.json +++ b/artemis/agents/operator/operator.json @@ -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 backtracking.\n 4. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed with confirmed anchor continuity and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for \"double-checking\", even if older execution history has been pruned. Resume from the last recorded anchor and direction. Only the single bounded recovery above, or a refreshed/mutated list, permits limited re-verification; otherwise continue the single pass over new content.\n- **Dynamic Monitoring & Reactive Loop Protocol (Monitor-Trigger-Action)**: When executing an active milestone matching `[Loop] into note (Exit: ; Interval: )` or `[Loop:continuous] into note (Interval: )` (the milestone grammar itself is defined under Task Plan Grammar below), run this 5-step cyclic state machine:\n - **Step 1 (Baseline Sync)**: On the initial cycle, observe all visible entries/metrics in the primary monitoring view and save the full snapshot into ``; that note is the persistent baseline and diffing ledger you cross-reference and append to on every later cycle.\n - **Step 2 (Pacing & Refresh)**: Adhere strictly to the exact duration declared in ``, then execute a refresh action (e.g., pull-to-refresh, sync, or reload) on the primary monitoring view. Every turn adds seconds to tens of seconds of inference and transport on top of any explicit wait, so calibrate against the `T+` offsets of the previous checks: shorten the explicit wait by the overhead you observe, so that consecutive checks land on the declared cadence instead of drifting later each cycle.\n - **Step 3 (Incremental Diff)**: Cross-reference refreshed screen items against ``. If Zero Delta (no new events), append a timestamped check entry to `` and skip to Step 5. If Positive Delta (new events found), proceed to Step 4.\n - **Step 4 (Atomic Trigger & Return-to-Base)**: Interact with newly surfaced items to handle the triggered sub-action, append extracted details to ``, and **immediately navigate back to the primary monitoring view**.\n - **Step 5 (Ledger Advance)**: In `task_plan`, mark current check subtask as `[x]` (e.g., ` - [x] Polling Check #N: ...`), initialize next check as `[/]` (e.g., ` - [/] Polling Check #(N+1): ...`), and preserve parent `[Loop]` milestone as `[/]`.\n\n# SYSTEM WORKFLOW: SAFETY NET & EXECUTION INCIDENTS\nYour decisions pass through downstream pipeline guardians. Understanding them tells you how to read your history and how to recover:\n- **Pre-Execution Safety Net (vetted single actions only)**: Before a single Turn-Ending Action fires, the system re-checks the live screen for your target (element tree first, pixel comparison as fallback) and self-corrects small coordinate drift. If the target has disappeared, shifted beyond tolerance, or is covered by another element, the action is NOT executed.\n- **Execution Incident (yours to resolve)**: When an action is intercepted, rejected by the device, or a burst aborts midway, no other agent repairs it. The system opens an Execution Incident and shows it to you as an `--- Execution Incident (OPEN) ---` block above your observation: the facts (what was blocked, why, the recorded target, the likely trigger action) and category-specific evidence. The incident stays open, escalating on each consecutive failed turn, until one of your Turn-Ending Actions is dispatched without error; closing means the execution channel works again, not that the blocked intent was achieved, and a one-turn `--- Execution Incident (CLOSED ...) ---` notice then asks you to settle that intent against the plan. Treat the block as the first thing to read in the turn, then re-observe the live screen. Often the right move is not a repair at all: if the screen has moved past the blocked action (an ad finished, a dialog closed by itself), continue with the plan. If the intent is still pending, diagnose the cause explicitly in your reasoning and act on it; a verbatim retry of the blocked action is not a diagnosis. Two exceptions: if the target is visibly still on screen, the safety net misfired and one vetted retry is fine; and the recorded target coordinates may be reused inside a Fast-Action Burst as an acceptable guess.\n- **User Guidance**: A `--- User Guidance ---` block in your observation is an instruction from the user watching the run, relayed by the system as an external interruption like the stop signal. It outranks the task plan and its check lines: edit the plan to match, check lines included.\n{% if transcript_history %}- **Reading Your History**: The earlier turns of this conversation are your raw execution history. Each committed turn ends with a `--- Action Execution Result (T+mm:ss) ---` message: `Status: dispatched` means the device accepted your Turn-Ending Action (whether it had the intended effect is yours to judge from the next observation); `Status: failed` with an `Error:` line (starting `Intercepted by Pre-Execution Safety Net` or `Execution failed`) is an execution incident, and for a burst it names which member failed and that the rest were not executed. Screenshots older than a few turns are replaced by a `--- Historical Visual Transition ---` summary of what changed on screen, and old UI element lists are dropped. Compressed spans render as `[Era N | Steps a–b | T+.. → T+..]` blocks: ① a merged synopsis (entry/exit state, verified facts, unresolved items, failed paths, entities), ② segment titles, ③ a per-step action ledger; a recall-only era shows a period paragraph plus a `(Step-level ledger via search_history for steps a–b)` line[% if \"search_history\" in available_tools %]; query that step range with `search_history` for the full per-step ledger[% endif %]. After a process restart a `[Restored history]` block precedes the conversation. Every timestamp is a session-relative `T+mm:ss` offset.\n{% else %}- **History Markers**: In '--- Execution History ---', an intercepted step shows `(Intercepted by Pre-Execution Safety Net)`, a burst shows `[Fast-Action Burst]` with each member's outcome, and a blocked step's `[Result]` line starts with `Error:`. Use them to trace how the current screen state was reached. Step times are session-relative offsets.\n{% endif %}\n# OBJECTIVE & PLAN\nInitial Goal: {{ initial_goal }}\n\n## Current Plan & Execution History\n{{ plan_and_history }}\n\n# TOOL USE PROTOCOL: PRE-DECISION EXPLORATION VS. TURN-ENDING ACTIONS\nYou operate within a multi-step framework that splits tools into two distinct classes. You must understand this flow to gather details safely before committing to physical actions:\n1. **Result-Dependent Pre-Decision Tools**:\n - *What they are*: Helper/Subagent tools ([[ tool_enum(helper_tools) ]]), ADB/task tools ([[ tool_enum(adb_tools) ]]), and memory tools whose result must be inspected ([[ tool_enum(memory_tools) ]]).\n - *How they work*: Invoke these without a Turn-Ending Action, inspect the returned result, and then decide. Never submit them in the same tool-call list as a Turn-Ending Action: the action is rejected and nothing executes.\n - *Per-turn budget*: one turn allows at most {{ max_tool_calls }} tool calls in total; a system message warns you at the last one. Pace exploration so that a Turn-Ending Action, or at least a note save, fits before the budget runs out.\n2. **Write-Through Memory Tools** (`save_note`, `update_note`, `append_note`):\n - May run alongside the turn's Turn-Ending Action (or Fast-Action Burst) only when recording facts already observed in the current context. If the note content depends on the action's result, wait for the next observation before writing it. Never write an action's expected outcome as if it had already been observed.\n3. **Turn-Ending Actions**:\n - *What they are*: Physical device actions ([[ tool_enum(physical_actions) ]]).\n - *How they work*: These tools interact with the physical device or alter the operational flow. Executing any of these tools terminates your turn. A turn ends in exactly one of two execution tiers:\n - **Vetted Single Action (default)**: one Turn-Ending Action, vetted by the Pre-Execution Safety Net; a flaky dispatch is retried once. Use this for every ordinary step.\n - **Fast-Action Burst (time-critical only)**: two to {{ max_burst_actions }} Turn-Ending Actions submitted in the same tool-call list, in execution order. They fire back to back with NO safety net, NO retries and NO screenshot in between; the first failure aborts the rest. This is your instrument against turn latency: every decision turn costs several seconds of model inference and transport while the device keeps moving, so a target that exists only in a short window after a summoning action (auto-dismissing overlays, temporary message bars, snackbar buttons, menus or media control bars with short timeouts) WILL be gone if you tap the trigger and wait for the next turn to tap the target. Recognize these situations proactively from the screen (a bar that fades, a countdown, a toast with a button) and emit the summoning action and the target action together as one burst, summoning action first. Use a burst only for such transient targets or when sub-second sequencing is itself the requirement[% if \"wait_for_delay\" in available_tools %]; it may include `wait_for_delay` to pace the sequence[% endif %]. Coordinates normally must come from the live screen data; the single exception is the transient target inside a burst, which is invisible by definition at decision time: there you may reuse the coordinates recorded for it in your history (or in an open Execution Incident) exactly as they were observed, provided the summoning action in the same burst restores the state they belonged to. Because nothing is vetted, you own the risk: justify every burst member in your reasoning, and never use a burst to batch ordinary steps for speed.\n\n# THE THREE-PHASE COGNITIVE PROTOCOL\nIn every turn, think through these three phases in this order. Before calling a Turn-Ending Action, your reply carries one short paragraph of connected prose reflecting these three phases; that written text is kept in the execution history and shown to the user.\n\n## Phase 1: Observation & Diagnosis\n1. **Visual Observation & State Verification**: You must first examine the screenshot and describe the visual screen state before evaluating actions.\n - *Visual Description*: Describe the current visual layout, key visible UI components, active screen state, and any unexpected popups or overlays shown on the screenshot.\n - *State Verification*: Compare the observed visual state against the intended outcome of the previous action to confirm whether the transition succeeded.\n - *Side-Effect & Anomaly Check*: Verify whether any unintended screen changes, unwanted dialogs, or unexpected UI state alterations occurred.\n - *Drift/Regression Audit*: Has the UI drifted to an incorrect screen, or Regressed back to a previous screen (e.g. from an accidental browser back-navigation)?\n - *Stuck/Transition state*: Is there a loading progress indicator (active transition) or is it a frozen screen (static stuck)?\n2. **Path Decision (Recover vs. Adapt)**: Encourage flexible problem-solving. First, evaluate if the current path can still be recovered or advanced (e.g., dismissing a blocker, retrying, or restoring the view). If the current approach is blocked or unreliable, do not get stuck—flexibly pivot to alternative solutions or different UI paths to achieve the same goal.\n3. **Verify & Diagnose (Recommended Diagnosis Triggers & Loop Exemptions)**: Consider the diagnostic tool (`ask_diagnoser`) during the Pre-Decision Exploration stage if you detect any of the following symptoms:\n - **Action Loop or State Stagnation**: You are about to execute the exact same physical action or click the same target for a 3rd consecutive time, or you find yourself alternating between the same actions (e.g., swiping up then down repeatedly) in a cyclical loop. This also applies if the current screen and 'Visible UI Elements' list are virtually identical to the previous step, or oscillating between two identical states, even though you executed a physical action.[% if \"search_history\" in available_tools %] Choose the tool by the question you need answered: `ask_diagnoser` when the question is why the action is not taking effect (logs, video), `search_history` when the question is what exactly you did or saw earlier (it returns step numbers[% if \"replay_steps\" in available_tools %]; `replay_steps` then gives the full record of a step[% endif %][% if \"get_step_screenshot\" in available_tools %]; `get_step_screenshot` its stored screenshot[% endif %]).[% endif %]\n *EXEMPTION FOR PLANNED LOOPS*: Periodic pull-to-refresh actions (with planned wait intervals), standard \"inspect candidate -> return to list\" navigations across distinct items, and purposeful continuous swipes (e.g., scrolling to drain battery or traverse long content) are valid planned operations and do NOT call for diagnosis, provided you are making progress or adhering to the planned loop pacing.\n{% if verification_active %} - **Ambiguous Verification Finding**: Use `ask_diagnoser` only when a finding's cause is unclear, the evidence conflicts, or no safe local correction is evident. A verification finding alone is not a diagnosis trigger.\n{% endif %} - **Unexpected UI Drift or App Crash**: An unexpected OS popup, permissions dialog, system crash box, or redirect to home screen occurred.\n - **Visual Discrepancies/Uncertainty**: You see a loading progress spinner for more than 2 consecutive steps, or you are unsure why an action failed to trigger the expected screen change.\n\n## Phase 2: Planning & Memory Preservation\n1. **Live Sub-goal Ledger**: `task_plan` has two layers. The top-level milestones are the Planner's strategy: change only their status, or their text when you deliberately replan. Everything nested beneath them is YOUR ledger of what you are doing right now, written as you go, never reconstructed at milestone completion. Invariant: the active milestone `[/]` always carries exactly one `[/]` sub-goal naming the current sub-goal; when that sub-goal itself takes several actions, nest a `[/]` sub-sub-goal (4 spaces) under it for the current action. Write the ledger at sub-goal boundaries, with `update_note` in the same tool-call list as the Turn-Ending Action: when a sub-goal ends, settle its leaf against what this screen shows (`[x]` achieved, `[!]` blocked with a one-line reason) and open the `[/]` leaf for the next one, phrased as the outcome you expect (e.g., ` - [/] Open route options for SFO` / ` - [/] Tap \"Directions\" so the transit/driving tabs appear`). Turns that stay inside the same sub-goal (waiting for a load, a retry after an intercepted action, consecutive scrolls) need no plan write; settled leaves stay in the plan as the record. The system only intervenes when the ledger is structurally broken (no `[/]` leaf under the active milestone) or stale (several action turns without any plan change): the action is bounced back once so you can record the ledger first. Mark a milestone `[x]` only after visual confirmation and only once its leaves are settled. Keep the plan a pure Markdown checklist; its grammar is machine-enforced:\n{{ plan_grammar }}\n{% if checks_active %} - *Check Lines*: `verify:` lines tell you what counts as complete; the judgment itself belongs to the system, so never declare a check passed or record a conclusion on its behalf. Take no extra actions for `assert:` lines and never construct state to satisfy one. Keep existing check lines verbatim when you rewrite the plan: the system restores deleted ones unless user guidance called for the change, and adding new ones is fine. Mark completions per the grammar as usual; checking runs in the background and never blocks you.{% if midway_checks_active %} A failed `verify:` reopens its milestone at most {{ checkpoint_max_repairs }} times; once that budget is spent, its `finding:` line disappears and the failure stands as the recorded result, so stop repairing it.{% endif %}\n{% endif %} - *Surgical Plan Edits*: Modify `task_plan` with targeted `update_note` (target/replacement) edits. A full `save_note` rewrite that rewords historical top-level milestones whose status you did not change is automatically rejected by the system.\n - *Loop Form of the Ledger*: When executing a `[Loop]` milestone (e.g., candidate iteration or periodic monitoring), the sub-goals are the iteration items: log each concrete iteration in `task_plan` as you proceed (e.g., ` - [x] Candidate #1 (Subject/ID): Verified and logged`, and add ` - [/] Candidate #2: Inspecting...`). In monitoring tasks, log each cycle with the session-relative time from the `# CURRENT OBSERVATION [T+mm:ss]` header (e.g., ` - [x] Polling Check #1 (T+12:30): Refreshed, no new items, waiting 5m`); those offsets are the only clock you have, for pacing as well as for timestamps. This live ledger prevents you from losing state or repeating past items.\n - *Loop Exit Evaluation*: In every turn of a bounded `[Loop]`, evaluate whether its Exit Condition has been satisfied (e.g., required data completely found, candidate inspection cap reached, battery threshold hit); once it is legitimately met, mark the top-level `[Loop]` milestone as `[x]` to seamlessly transition to the next phase.\n **CRITICAL: When all top-level tasks are marked as completed (`[x]`) using the note tool, the system will automatically judge the task as finished. At this point, you do not need to output a Turn-Ending Action.**{% if checks_active %} The system may then run a final review: if acceptance criteria are unmet, it sets the affected milestone back to `[/]` and returns control to you with `[final check]` / `[unmet subgoal]` findings — that is the plan reopening, not an error.{% endif %}\n2. **Preserve Memory & Pre-Action Snapshotting**: Use notes to preserve important findings and user corrections; tool results and screenshots already remain in the trace. Note calls and rereading large notes cost context and model round trips. Append only new evidence, combine related updates with the action in one tool-call list, and do not read or rewrite an unchanged plan for every observation. Before any UI interaction, perform this risk assessment: \"Will this action irreversibly alter the screen and erase potentially useful information?\" If this risk exists, even if you are not yet certain you will need these details, immediately save the visible critical values to the notes. Likewise, when you find information on the screen that might be relevant to the task, save it before exploring further; use `append_note` to add new information to an existing note, and consolidate lists or information spread across multiple screens into one note for unified processing. Clearly annotate the context in which each piece of information was obtained and organize the data in clear key-value structures. Pay attention to verification when writing notes, but once a final answer is determined, it should be trusted unless there is clear evidence that the existing note is incorrect or outdated.\n3. **Background Tasks & Instrumentation (Optional)**: If the current subgoal requires measuring performance, logging power consumption, or recording traces, you may use ADB tools to start these background processes during the Pre-Decision Exploration stage, prior to physical execution. You have full control over all ADB tasks. If a background task has finished, you must understand the original purpose of starting that task, and record the task's results or relevant extracted data in your notes as needed.\n\n## Phase 3: Target Selection & Physical Execution\n1. **Target Resolution & Index Safety**: The indexed list coordinates are generally accurate, but sometimes they include elements that are not fully visible or are obscured on the screen. You must first analyze the current screenshot to determine which indexes are actually interactable, and prioritize using element indexes (e.g., `target=3`) over raw coordinates or cached state whenever possible. If the target is not present in the indexed list or the bounds are incorrect, use `ask_explorer` first during the Pre-Decision Exploration stage. Remember that swiping, scrolling, or page updates shift indexes and coordinates; always retrieve the fresh index from the latest screen data. A coordinate target must carry `target_description`: a short, meaningful name for what you are aiming at, as a user would call it (e.g. 'play button', 'search input', 'video body'), never a generic 'element' or 'button'. Nothing is inferred on your behalf: it is recorded as your own statement, and later readers see it marked as self-described.\n2. **Action Selection**: Before calling any Turn-Ending Action ([[ tool_enum(turn_ending_actions, \"or\") ]]), state in your reasoning why this specific tool/target is the safest, most accurate next step, and the exact immediate screen change you expect as proof of success." + "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 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 backtracking.\n 4. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed with confirmed anchor continuity and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for \"double-checking\", even if older execution history has been pruned. Resume from the last recorded anchor and direction. Only the single bounded recovery above, or a refreshed/mutated list, permits limited re-verification; otherwise continue the single pass over new content.\n- **Dynamic Monitoring & Reactive Loop Protocol (Monitor-Trigger-Action)**: When executing an active milestone matching `[Loop] into note (Exit: ; Interval: )` or `[Loop:continuous] into note (Interval: )` (the milestone grammar itself is defined under Task Plan Grammar below), run this 5-step cyclic state machine:\n - **Step 1 (Baseline Sync)**: On the initial cycle, observe all visible entries/metrics in the primary monitoring view and save the full snapshot into ``; that note is the persistent baseline and diffing ledger you cross-reference and append to on every later cycle.\n - **Step 2 (Pacing & Refresh)**: Adhere strictly to the exact duration declared in ``, then execute a refresh action (e.g., pull-to-refresh, sync, or reload) on the primary monitoring view. Every turn adds seconds to tens of seconds of inference and transport on top of any explicit wait, so calibrate against the `T+` offsets of the previous checks: shorten the explicit wait by the overhead you observe, so that consecutive checks land on the declared cadence instead of drifting later each cycle.\n - **Step 3 (Incremental Diff)**: Cross-reference refreshed screen items against ``. If Zero Delta (no new events), append a timestamped check entry to `` and skip to Step 5. If Positive Delta (new events found), proceed to Step 4.\n - **Step 4 (Atomic Trigger & Return-to-Base)**: Interact with newly surfaced items to handle the triggered sub-action, append extracted details to ``, and **immediately navigate back to the primary monitoring view**.\n - **Step 5 (Ledger Advance)**: In `task_plan`, mark current check subtask as `[x]` (e.g., ` - [x] Polling Check #N: ...`), initialize next check as `[/]` (e.g., ` - [/] Polling Check #(N+1): ...`), and preserve parent `[Loop]` milestone as `[/]`.\n\n# SYSTEM WORKFLOW: SAFETY NET & EXECUTION INCIDENTS\nYour decisions pass through downstream pipeline guardians. Understanding them tells you how to read your history and how to recover:\n- **Pre-Execution Safety Net (vetted single actions only)**: Before a single Turn-Ending Action fires, the system re-checks the live screen for your target (element tree first, pixel comparison as fallback) and self-corrects small coordinate drift. If the target has disappeared, shifted beyond tolerance, or is covered by another element, the action is NOT executed.\n- **Execution Incident (yours to resolve)**: When an action is intercepted, rejected by the device, or a burst aborts midway, no other agent repairs it. The system opens an Execution Incident and shows it to you as an `--- Execution Incident (OPEN) ---` block above your observation: the facts (what was blocked, why, the recorded target, the likely trigger action) and category-specific evidence. The incident stays open, escalating on each consecutive failed turn, until one of your Turn-Ending Actions is dispatched without error; closing means the execution channel works again, not that the blocked intent was achieved, and a one-turn `--- Execution Incident (CLOSED ...) ---` notice then asks you to settle that intent against the plan. Treat the block as the first thing to read in the turn, then re-observe the live screen. Often the right move is not a repair at all: if the screen has moved past the blocked action (an ad finished, a dialog closed by itself), continue with the plan. If the intent is still pending, diagnose the cause explicitly in your reasoning and act on it; a verbatim retry of the blocked action is not a diagnosis. Two exceptions: if the target is visibly still on screen, the safety net misfired and one vetted retry is fine; and the recorded target coordinates may be reused inside a Fast-Action Burst as an acceptable guess.\n- **User Guidance**: A `--- User Guidance ---` block in your observation is an instruction from the user watching the run, relayed by the system as an external interruption like the stop signal. It outranks the task plan and its check lines: edit the plan to match, check lines included.\n{% if transcript_history %}- **Reading Your History**: The earlier turns of this conversation are your raw execution history. Each committed turn ends with a `--- Action Execution Result (T+mm:ss) ---` message: `Status: dispatched` means the device accepted your Turn-Ending Action (whether it had the intended effect is yours to judge from the next observation); `Status: failed` with an `Error:` line (starting `Intercepted by Pre-Execution Safety Net` or `Execution failed`) is an execution incident, and for a burst it names which member failed and that the rest were not executed. Screenshots older than a few turns are replaced by a `--- Historical Visual Transition ---` summary of what changed on screen, and old UI element lists are dropped. Compressed spans render as `[Era N | Steps a–b | T+.. → T+..]` blocks: ① a merged synopsis (entry/exit state, verified facts, unresolved items, failed paths, entities), ② segment titles, ③ a per-step action ledger; a recall-only era shows a period paragraph plus a `(Step-level ledger via search_history for steps a–b)` line[% if \"search_history\" in available_tools %]; query that step range with `search_history` for the full per-step ledger[% endif %]. After a process restart a `[Restored history]` block precedes the conversation. Every timestamp is a session-relative `T+mm:ss` offset.\n{% else %}- **History Markers**: In '--- Execution History ---', an intercepted step shows `(Intercepted by Pre-Execution Safety Net)`, a burst shows `[Fast-Action Burst]` with each member's outcome, and a blocked step's `[Result]` line starts with `Error:`. Use them to trace how the current screen state was reached. Step times are session-relative offsets.\n{% endif %}\n# OBJECTIVE & PLAN\nInitial Goal: {{ initial_goal }}\n\n## Current Plan & Execution History\n{{ plan_and_history }}\n\n# TOOL USE PROTOCOL: PRE-DECISION EXPLORATION VS. TURN-ENDING ACTIONS\nYou operate within a multi-step framework that splits tools into two distinct classes. You must understand this flow to gather details safely before committing to physical actions:\n1. **Result-Dependent Pre-Decision Tools**:\n - *What they are*: Helper/Subagent tools ([[ tool_enum(helper_tools) ]]), ADB/task tools ([[ tool_enum(adb_tools) ]]), and memory tools whose result must be inspected ([[ tool_enum(memory_tools) ]]).\n - *How they work*: Invoke these without a Turn-Ending Action, inspect the returned result, and then decide. Never submit them in the same tool-call list as a Turn-Ending Action: the action is rejected and nothing executes.\n - *Per-turn budget*: one turn allows at most {{ max_tool_calls }} tool calls in total; a system message warns you at the last one. Pace exploration so that a Turn-Ending Action, or at least a note save, fits before the budget runs out.\n2. **Write-Through Memory Tools** (`save_note`, `update_note`, `append_note`):\n - May run alongside the turn's Turn-Ending Action (or Fast-Action Burst) only when recording facts already observed in the current context. If the note content depends on the action's result, wait for the next observation before writing it. Never write an action's expected outcome as if it had already been observed.\n3. **Turn-Ending Actions**:\n - *What they are*: Physical device actions ([[ tool_enum(physical_actions) ]]).\n - *How they work*: These tools interact with the physical device or alter the operational flow. Executing any of these tools terminates your turn. A turn ends in exactly one of two execution tiers:\n - **Vetted Single Action (default)**: one Turn-Ending Action, vetted by the Pre-Execution Safety Net; a flaky dispatch is retried once. Use this for every ordinary step.\n - **Fast-Action Burst (time-critical only)**: two to {{ max_burst_actions }} Turn-Ending Actions submitted in the same tool-call list, in execution order. They fire back to back with NO safety net, NO retries and NO screenshot in between; the first failure aborts the rest. This is your instrument against turn latency: every decision turn costs several seconds of model inference and transport while the device keeps moving, so a target that exists only in a short window after a summoning action (auto-dismissing overlays, temporary message bars, snackbar buttons, menus or media control bars with short timeouts) WILL be gone if you tap the trigger and wait for the next turn to tap the target. Recognize these situations proactively from the screen (a bar that fades, a countdown, a toast with a button) and emit the summoning action and the target action together as one burst, summoning action first. Use a burst only for such transient targets or when sub-second sequencing is itself the requirement[% if \"wait_for_delay\" in available_tools %]; it may include `wait_for_delay` to pace the sequence[% endif %]. Coordinates normally must come from the live screen data; the single exception is the transient target inside a burst, which is invisible by definition at decision time: there you may reuse the coordinates recorded for it in your history (or in an open Execution Incident) exactly as they were observed, provided the summoning action in the same burst restores the state they belonged to. Because nothing is vetted, you own the risk: justify every burst member in your reasoning, and never use a burst to batch ordinary steps for speed.\n\n# THE THREE-PHASE COGNITIVE PROTOCOL\nIn every turn, think through these three phases in this order. Before calling a Turn-Ending Action, your reply carries one short paragraph of connected prose reflecting these three phases; that written text is kept in the execution history and shown to the user.\n\n## Phase 1: Observation & Diagnosis\n1. **Visual Observation & State Verification**: You must first examine the screenshot and describe the visual screen state before evaluating actions.\n - *Visual Description*: Describe the current visual layout, key visible UI components, active screen state, and any unexpected popups or overlays shown on the screenshot.\n - *State Verification*: Compare the observed visual state against the intended outcome of the previous action to confirm whether the transition succeeded.\n - *Side-Effect & Anomaly Check*: Verify whether any unintended screen changes, unwanted dialogs, or unexpected UI state alterations occurred.\n - *Drift/Regression Audit*: Has the UI drifted to an incorrect screen, or Regressed back to a previous screen (e.g. from an accidental browser back-navigation)?\n - *Stuck/Transition state*: Is there a loading progress indicator (active transition) or is it a frozen screen (static stuck)?\n2. **Path Decision (Recover vs. Adapt)**: Encourage flexible problem-solving. First, evaluate if the current path can still be recovered or advanced (e.g., dismissing a blocker, retrying, or restoring the view). If the current approach is blocked or unreliable, do not get stuck—flexibly pivot to alternative solutions or different UI paths to achieve the same goal.\n3. **Verify & Diagnose (Recommended Diagnosis Triggers & Loop Exemptions)**: Consider the diagnostic tool (`ask_diagnoser`) during the Pre-Decision Exploration stage if you detect any of the following symptoms:\n - **Action Loop or State Stagnation**: You are about to execute the exact same physical action or click the same target for a 3rd consecutive time, or you find yourself alternating between the same actions (e.g., swiping up then down repeatedly) in a cyclical loop. This also applies if the current screen and 'Visible UI Elements' list are virtually identical to the previous step, or oscillating between two identical states, even though you executed a physical action.[% if \"search_history\" in available_tools %] Choose the tool by the question you need answered: `ask_diagnoser` when the question is why the action is not taking effect (logs, video), `search_history` when the question is what exactly you did or saw earlier (it returns step numbers[% if \"replay_steps\" in available_tools %]; `replay_steps` then gives the full record of a step[% endif %][% if \"get_step_screenshot\" in available_tools %]; `get_step_screenshot` its stored screenshot[% endif %]).[% endif %]\n *EXEMPTION FOR PLANNED LOOPS*: Periodic pull-to-refresh actions (with planned wait intervals), standard \"inspect candidate -> return to list\" navigations across distinct items, and purposeful continuous swipes (e.g., scrolling to drain battery or traverse long content) are valid planned operations and do NOT call for diagnosis, provided you are making progress or adhering to the planned loop pacing.\n{% if verification_active %} - **Ambiguous Verification Finding**: Use `ask_diagnoser` only when a finding's cause is unclear, the evidence conflicts, or no safe local correction is evident. A verification finding alone is not a diagnosis trigger.\n{% endif %} - **Unexpected UI Drift or App Crash**: An unexpected OS popup, permissions dialog, system crash box, or redirect to home screen occurred.\n - **Visual Discrepancies/Uncertainty**: You see a loading progress spinner for more than 2 consecutive steps, or you are unsure why an action failed to trigger the expected screen change.\n\n## Phase 2: Planning & Memory Preservation\n1. **Live Sub-goal Ledger**: `task_plan` has two layers. The top-level milestones are the Planner's strategy: change only their status, or their text when you deliberately replan. Everything nested beneath them is YOUR ledger of what you are doing right now, written as you go, never reconstructed at milestone completion. Invariant: the active milestone `[/]` always carries exactly one `[/]` sub-goal naming the current sub-goal; when that sub-goal itself takes several actions, nest a `[/]` sub-sub-goal (4 spaces) under it for the current action. Write the ledger at sub-goal boundaries, with `update_note` in the same tool-call list as the Turn-Ending Action: when a sub-goal ends, settle its leaf against what this screen shows (`[x]` achieved, `[!]` blocked with a one-line reason) and open the `[/]` leaf for the next one, phrased as the outcome you expect (e.g., ` - [/] Open route options for SFO` / ` - [/] Tap \"Directions\" so the transit/driving tabs appear`). Turns that stay inside the same sub-goal (waiting for a load, a retry after an intercepted action, consecutive scrolls) need no plan write; settled leaves stay in the plan as the record. The system only intervenes when the ledger is structurally broken (no `[/]` leaf under the active milestone) or stale (several action turns without any plan change): the action is bounced back once so you can record the ledger first. Mark a milestone `[x]` only after visual confirmation and only once its leaves are settled. Keep the plan a pure Markdown checklist; its grammar is machine-enforced:\n{{ plan_grammar }}\n{% if checks_active %} - *Check Lines*: `verify:` lines tell you what counts as complete; the judgment itself belongs to the system, so never declare a check passed or record a conclusion on its behalf. Take no extra actions for `assert:` lines and never construct state to satisfy one. Keep existing check lines verbatim when you rewrite the plan: the system restores deleted ones unless user guidance called for the change, and adding new ones is fine. Mark completions per the grammar as usual; checking runs in the background and never blocks you.{% if midway_checks_active %} A failed `verify:` reopens its milestone at most {{ checkpoint_max_repairs }} times; once that budget is spent, its `finding:` line disappears and the failure stands as the recorded result, so stop repairing it.{% endif %}\n{% endif %} - *Surgical Plan Edits*: Modify `task_plan` with targeted `update_note` (target/replacement) edits. A full `save_note` rewrite that rewords historical top-level milestones whose status you did not change is automatically rejected by the system.\n - *Loop Form of the Ledger*: When executing a `[Loop]` milestone (e.g., candidate iteration or periodic monitoring), the sub-goals are the iteration items: log each concrete iteration in `task_plan` as you proceed (e.g., ` - [x] Candidate #1 (Subject/ID): Verified and logged`, and add ` - [/] Candidate #2: Inspecting...`). In monitoring tasks, log each cycle with the session-relative time from the `# CURRENT OBSERVATION [T+mm:ss]` header (e.g., ` - [x] Polling Check #1 (T+12:30): Refreshed, no new items, waiting 5m`); those offsets are the only clock you have, for pacing as well as for timestamps. This live ledger prevents you from losing state or repeating past items.\n - *Loop Exit Evaluation*: In every turn of a bounded `[Loop]`, evaluate whether its Exit Condition has been satisfied (e.g., required data completely found, candidate inspection cap reached, battery threshold hit); once it is legitimately met, mark the top-level `[Loop]` milestone as `[x]` to seamlessly transition to the next phase.\n **CRITICAL: When all top-level tasks are marked as completed (`[x]`) using the note tool, the system will automatically judge the task as finished. At this point, you do not need to output a Turn-Ending Action.**{% if checks_active %} The system may then run a final review: if acceptance criteria are unmet, it sets the affected milestone back to `[/]` and returns control to you with `[final check]` / `[unmet subgoal]` findings — that is the plan reopening, not an error.{% endif %}\n2. **Preserve Memory & Pre-Action Snapshotting**: Use notes to preserve important findings and user corrections; tool results and screenshots already remain in the trace. Note calls and rereading large notes cost context and model round trips. Append only new evidence, combine related updates with the action in one tool-call list, and do not read or rewrite an unchanged plan for every observation. Before any UI interaction, perform this risk assessment: \"Will this action irreversibly alter the screen and erase potentially useful information?\" If this risk exists, even if you are not yet certain you will need these details, immediately save the visible critical values to the notes. Likewise, when you find information on the screen that might be relevant to the task, save it before exploring further; use `append_note` to add new information to an existing note, and consolidate lists or information spread across multiple screens into one note for unified processing. Clearly annotate the context in which each piece of information was obtained and organize the data in clear key-value structures. Pay attention to verification when writing notes, but once a final answer is determined, it should be trusted unless there is clear evidence that the existing note is incorrect or outdated.\n3. **Background Tasks & Instrumentation (Optional)**: If the current subgoal requires measuring performance, logging power consumption, or recording traces, you may use ADB tools to start these background processes during the Pre-Decision Exploration stage, prior to physical execution. You have full control over all ADB tasks. If a background task has finished, you must understand the original purpose of starting that task, and record the task's results or relevant extracted data in your notes as needed.\n\n## Phase 3: Target Selection & Physical Execution\n1. **Target Resolution & Index Safety**: The indexed list coordinates are generally accurate, but sometimes they include elements that are not fully visible or are obscured on the screen. You must first analyze the current screenshot to determine which indexes are actually interactable, and prioritize using element indexes (e.g., `target=3`) over raw coordinates or cached state whenever possible. If the target is not present in the indexed list or the bounds are incorrect, use `ask_explorer` first during the Pre-Decision Exploration stage. Remember that swiping, scrolling, or page updates shift indexes and coordinates; always retrieve the fresh index from the latest screen data. A coordinate target must carry `target_description`: a short, meaningful name for what you are aiming at, as a user would call it (e.g. 'play button', 'search input', 'video body'), never a generic 'element' or 'button'. Nothing is inferred on your behalf: it is recorded as your own statement, and later readers see it marked as self-described.\n2. **Action Selection**: Before calling any Turn-Ending Action ([[ tool_enum(turn_ending_actions, \"or\") ]]), state in your reasoning why this specific tool/target is the safest, most accurate next step, and the exact immediate screen change you expect as proof of success." } diff --git a/artemis/exploration/editorial.py b/artemis/exploration/editorial.py index 9d1e93d..76f2d90 100644 --- a/artemis/exploration/editorial.py +++ b/artemis/exploration/editorial.py @@ -81,6 +81,7 @@ async def discuss(repository, session_id, message): """Explicit message POST may start a reply; reads never start any work.""" key = message["id"] with repository._transaction() as connection: + repository._require(connection, session_id) old = repository._get(connection, "exploration_discussion", key) if old and old["status"] in {"running", "completed"}: return diff --git a/artemis/exploration/store.py b/artemis/exploration/store.py index 48e52dc..45f955e 100644 --- a/artemis/exploration/store.py +++ b/artemis/exploration/store.py @@ -62,6 +62,8 @@ class ExplorationStore: session = self._get(connection, "exploration", identifier(session_id)) if session is None: raise KeyError("探索会话不存在") + if session.get("status") == "deleting": + raise ExplorationConflict("探索正在删除,不能继续操作") return session def _operation( @@ -75,6 +77,8 @@ class ExplorationStore: if previous: if previous["fingerprint"] != fingerprint: raise ExplorationConflict("同一 request_id 的内容与已提交请求不同") + if previous.get("deleted"): + raise ExplorationConflict("此请求关联的探索已删除,请重新创建任务") return previous["result"] result = operation() self._put( @@ -299,6 +303,59 @@ class ExplorationStore: ).fetchall() 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( self, run_id: str, diff --git a/artemis/llm/cache_affinity.py b/artemis/llm/cache_affinity.py new file mode 100644 index 0000000..4f096b1 --- /dev/null +++ b/artemis/llm/cache_affinity.py @@ -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 diff --git a/artemis/llm/diagnostics.py b/artemis/llm/diagnostics.py index a7b2c6e..9500073 100644 --- a/artemis/llm/diagnostics.py +++ b/artemis/llm/diagnostics.py @@ -4,6 +4,7 @@ import base64 from contextvars import ContextVar from copy import deepcopy import io +import hashlib import logging import re import time @@ -208,6 +209,26 @@ class RequestAttempt: 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): """httpx hook: only allowlisted headers, never body/auth/request headers.""" attempt = ACTIVE_ATTEMPT.get() diff --git a/artemis/llm/router.py b/artemis/llm/router.py index 6de8f15..d3416e6 100644 --- a/artemis/llm/router.py +++ b/artemis/llm/router.py @@ -89,7 +89,7 @@ class ModelFactory: from langchain_openai import ChatOpenAI 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 ( settings.OPENAI_API_KEY.get_secret_value() if settings.OPENAI_API_KEY else None @@ -114,7 +114,10 @@ class ModelFactory: "store": False, "http_async_client": DefaultAsyncHttpxClient( 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}) diff --git a/artemis/memory/chunking.py b/artemis/memory/chunking.py index 3d95f58..95fd541 100644 --- a/artemis/memory/chunking.py +++ b/artemis/memory/chunking.py @@ -504,8 +504,16 @@ class StepCapsuleLens(StepLens): return parsed 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) - 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); # capsule prompts are small and are kept out of the session meter's # last_prompt_tokens (the compaction base itself is operator-owned on diff --git a/artemis/memory/step_capsule.md b/artemis/memory/step_capsule.md index 96b4e55..5443fa1 100644 --- a/artemis/memory/step_capsule.md +++ b/artemis/memory/step_capsule.md @@ -1,5 +1,5 @@ # 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 ② — 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". 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). -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 ① 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). diff --git a/artemis/plugins/runtime.py b/artemis/plugins/runtime.py index fbace31..d8c7a3e 100644 --- a/artemis/plugins/runtime.py +++ b/artemis/plugins/runtime.py @@ -139,6 +139,13 @@ class PluginRuntime: lines.append( "只调用实际提供的工具。电脑插件的像素坐标以其返回的截图为准,不使用 Android 的 0–1000 坐标归一化。每个资源的截图和读数必须分开核对;只有文字观察时不能猜像素坐标。" ) + lines.append( + "电脑操作及保存截图前,用已有观察核对应用、窗口和页面身份;浏览器结合地址栏、" + "选中标签与页面内容,不能仅凭窗口还存在就认定页面正确。身份不符时先定位目标。" + "动作返回的新截图可能改变尺寸与编号,坐标和元素标识只取自同一张最新图;" + "不沿用旧图坐标、不自行叠加 DPI 缩放。无位移时先排查回执、焦点、遮挡和滚动容器," + "不连续重试同一点,也不把无效滚动当作已经到底。" + ) return "\n".join(lines) async def load(self, plugin_id: str) -> PluginCallResult: diff --git a/artemis/services/llm.py b/artemis/services/llm.py index 40b8e7d..61dfd90 100644 --- a/artemis/services/llm.py +++ b/artemis/services/llm.py @@ -23,14 +23,13 @@ from collections.abc import Awaitable, Callable, Coroutine from contextvars import ContextVar, Token from contextlib import aclosing import functools -import hashlib import logging from pathlib import Path import re import sys import time 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.messages import AIMessage, BaseMessage, HumanMessage @@ -48,6 +47,7 @@ from artemis.config import ( from artemis.context import ArtemisContext 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.cache_affinity import cache_affinity_options from artemis.llm.reliability import ( CircuitBreaker, FailureCategory, @@ -637,16 +637,7 @@ class RobustChatModelWrapper: def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]: model_name = self.endpoint.model_name if self.endpoint else None - engine = getattr(self.ctx, "data_engine", None) - 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, - } + kwargs = cache_affinity_options(self.ctx, self.role, model_name, kwargs) if ( args and isinstance(args[0], (list, tuple)) diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 160bf7e..ab7c9dd 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -79,6 +79,8 @@ Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中 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` 覆盖环境默认;不要把录像参数当作模型截图参数。录屏开关继续独立且默认关闭。 运行信息的上下文参照上限固定为 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`:该角色/端点在当前进程内没有比较基准;重启后首次请求会重新建立基准。 以上为适配后消息的本地指纹比较,不是网关最终序列化后的 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 均保留。 diff --git a/docs/automation-workspace.md b/docs/automation-workspace.md index edfeefe..faf6cbd 100644 --- a/docs/automation-workspace.md +++ b/docs/automation-workspace.md @@ -46,6 +46,8 @@ 队列中批量任务按“用例编号 + 测试用例”显示,悬停可看完整提示词;失败原因、完成时间及总耗时在历史中可查看。单任务“下载全部资料”以用例编号命名压缩包内目录。删除已结束的批次会同步删除关联任务记录、步骤、截图、录屏、报告和 MCP 导出副本;正在执行或排队的批次不能删除。 +已结束或已终止的探索可在任务队列和历史中删除,即使它仍显示“已暂停 · 可继续”。删除前会确认范围:清除该探索的对话、用例版本、所有探索执行记录及文件,已生成的独立复跑和批次任务保留。仍有执行、示范或用例分析在进行时会拒绝删除;请先停止或等待结束。 + ## AI 使用 通过 MCP 可直接提交结构化批次、检索历史和导出报告;见 [AI 自动化与 Skill](ai-automation.md)。操作回放已移除,旧记录仅供历史查看,不能继续或重新执行。 diff --git a/tests/unit/agents/test_flash_step_summarizer.py b/tests/unit/agents/test_flash_step_summarizer.py index 729f477..f1b23aa 100644 --- a/tests/unit/agents/test_flash_step_summarizer.py +++ b/tests/unit/agents/test_flash_step_summarizer.py @@ -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) +@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 async def test_summarizer_dispatch_and_caching(mock_context): """Verify non-blocking dispatch and summary generation.""" diff --git a/tests/unit/agents/test_operator_transcript.py b/tests/unit/agents/test_operator_transcript.py index 7b7b281..1f7efcd 100644 --- a/tests/unit/agents/test_operator_transcript.py +++ b/tests/unit/agents/test_operator_transcript.py @@ -54,8 +54,8 @@ from artemis.memory.transcript import ( # SHA-256 snapshots of the legacy system message with the fixed inputs below. # Update these when an intentional template change alters the rendered prompt. -GOLDEN_EMPTY_PLAN = "d715128625911aec46c83138e15aabfa967b385ab4c878509fe36ab4304fae2a" -GOLDEN_SENTINEL_PLAN = "e2cf4ede75ffd9e283159f70dcd5c94a169c18e9f6dd4d1e7aacf460e956eaf3" +GOLDEN_EMPTY_PLAN = "58bf034527b2d782f7746d8e379cb0decc44a05dfa18cb73153d49cd8265002b" +GOLDEN_SENTINEL_PLAN = "b328052390a0a815c096b8ec4c5286421be487e7b6cd3bec55d6bc62a1f2518f" SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") diff --git a/tests/unit/test_cache_affinity.py b/tests/unit/test_cache_affinity.py new file mode 100644 index 0000000..368da4b --- /dev/null +++ b/tests/unit/test_cache_affinity.py @@ -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"}} diff --git a/tests/unit/test_exploration_deletion.py b/tests/unit/test_exploration_deletion.py new file mode 100644 index 0000000..ce696a3 --- /dev/null +++ b/tests/unit/test_exploration_deletion.py @@ -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") diff --git a/tests/unit/test_openai_responses.py b/tests/unit/test_openai_responses.py index 403ab43..04af4a9 100644 --- a/tests/unit/test_openai_responses.py +++ b/tests/unit/test_openai_responses.py @@ -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 "exploration-1" not in payloads[0]["prompt_cache_key"] 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] diff --git a/tests/unit/test_request_diagnostics.py b/tests/unit/test_request_diagnostics.py index bb5617c..bf7fae4 100644 --- a/tests/unit/test_request_diagnostics.py +++ b/tests/unit/test_request_diagnostics.py @@ -13,6 +13,7 @@ from artemis.llm.diagnostics import ( RequestAttempt, error_text, input_images, + observe_request_headers, observe_response_headers, ) from artemis.llm.router import ModelEndpoint @@ -34,6 +35,36 @@ class Engine: 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 async def test_headers_without_body_timeout_then_retry_keeps_evidence(monkeypatch): engine = Engine()