From 3dd96078db7abd4914052711ff3145f9d5c3f1df Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Mon, 2 Nov 2026 17:15:59 +0800 Subject: [PATCH] feat: add cache routing alignment and usage visibility for 2.5.0 --- README.md | 2 +- apps/showcase_ui/package-lock.json | 4 +- apps/showcase_ui/package.json | 2 +- .../agent-stream-run-info.spec.ts | 127 ++++++++++++++++++ .../agent-stream/agent-stream.component.html | 15 +++ .../agent-stream/agent-stream.component.ts | 33 ++++- .../src/app/utils/run-info.util.spec.ts | 15 +++ .../src/app/utils/run-info.util.ts | 8 ++ artemis/llm/cache_affinity.py | 11 +- docs/ai-automation.md | 12 +- docs/releases/2.5.0.md | 46 +++++++ pyproject.toml | 2 +- tests/unit/test_cache_affinity.py | 34 +++++ tests/unit/test_openai_responses.py | 18 +++ uv.lock | 2 +- 15 files changed, 316 insertions(+), 15 deletions(-) create mode 100644 apps/showcase_ui/src/app/components/agent-stream/agent-stream-run-info.spec.ts create mode 100644 docs/releases/2.5.0.md diff --git a/README.md b/README.md index e065516..d38b766 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 -**2.4.5**:补齐插件开发辅助接口与市场材料检查,提供已批准文件快照、受管外部工具、故障自测和可运行模板。新 SDK 需 ARTEMIS 2.4.5;现有插件继续按自身清单兼容旧版本。见 [2.4.5 发行说明](docs/releases/2.4.5.md)与 [SDK 使用说明](docs/plugin-sdk.md)。人工评审插件保持 1.1.0;任务说明折叠见 [2.4.4](docs/releases/2.4.4.md),批次异常隔离见 [2.4.3](docs/releases/2.4.3.md)。 +**2.5.0**:新增通用动作序列、临时控件准备与刻度校准拖动;补齐参数流保护、取消关闭与诊断,优化会话缓存键一致性,并在执行模式弹窗显示缓存 Token 和加权命中率。见 [2.5.0 发行说明](docs/releases/2.5.0.md)。本版包含 [2.4.5 的插件开发 SDK](docs/releases/2.4.5.md),SDK 与插件继续独立版本化;升级主程序不会自动更新或授权插件。 ## 主要功能 diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 5b77445..4098546 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "2.4.5", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "2.4.5", + "version": "2.5.0", "dependencies": { "@angular/cdk": "^22.2.4", "@angular/common": "^22.2.3", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index 63ef40a..67695f7 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "2.4.5", + "version": "2.5.0", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-run-info.spec.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-run-info.spec.ts new file mode 100644 index 0000000..a8cff42 --- /dev/null +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-run-info.spec.ts @@ -0,0 +1,127 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { computed, signal } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Subject } from 'rxjs'; +import { AgentService } from '../../services/agent.service'; +import { SessionUsage } from '../../core/models/session.model'; +import { AgentStreamComponent } from './agent-stream.component'; + +describe('execution mode cache usage', () => { + let fixture: ComponentFixture; + let component: AgentStreamComponent; + let service: AgentService; + let overlay: OverlayContainer; + let requests: Subject[]; + const running = signal(false); + + const usage = (session_id = 'cache-session', cached_tokens = 90000): SessionUsage => ({ + session_id, llm_calls: 4, prompt_tokens: 305000, completion_tokens: 2100, + total_tokens: 307100, cached_tokens, operator_context_tokens: 150000, + operator_context_window_tokens: 300000 + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])] + }); + service = TestBed.inject(AgentService); + running.set(false); + service.currentSessionId.set('cache-session'); + spyOn(service, 'currentSession').and.callFake(computed(() => ({ + session_id: service.currentSessionId() || '', initial_goal: '缓存统计测试', + start_time: 1000, end_time: 1090, status: running() ? 'running' : 'completed' + }))); + service.activeModel.set({ name: 'Flash', id: 'test-model', provider: 'openai' }); + requests = []; + spyOn(service, 'getSessionUsage').and.callFake(() => { + const response = new Subject(); + requests.push(response); + return response.asObservable(); + }); + fixture = TestBed.createComponent(AgentStreamComponent); + component = fixture.componentInstance; + overlay = TestBed.inject(OverlayContainer); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('.model-info-chip') as HTMLButtonElement; + trigger.click(); + fixture.detectChanges(); + }); + + afterEach(() => overlay.ngOnDestroy()); + + const text = () => TestBed.inject(OverlayContainer).getContainerElement().textContent || ''; + + it('renders cumulative cache input and the token-weighted rate without adding cached input to totals', () => { + expect(text()).toContain('正在读取'); + requests[0].next(usage()); + fixture.detectChanges(); + expect(text()).toContain('缓存命中率'); + expect(text()).toContain('29.5%'); + expect(text()).toContain('90k 缓存 Token'); + expect(text()).toContain('307.1k'); + expect(text()).not.toContain('397.1k'); + requests[0].next(usage('cache-session', 0)); + fixture.detectChanges(); + expect(text()).toContain('0%'); + expect(text()).toContain('0 缓存 Token'); + }); + + it('shows unavailable usage and fetch errors without inventing a hit rate', () => { + requests[0].next({ ...usage(), prompt_tokens: 0, cached_tokens: 0 }); + fixture.detectChanges(); + const row = overlay.getContainerElement().querySelector('[title^="本任务累计缓存输入"]'); + expect(row?.textContent).toContain('—'); + requests[0].error(new Error('offline')); + fixture.detectChanges(); + expect(text()).toContain('读取失败'); + expect(text()).toContain('刷新失败'); + }); + + it('keeps cache statistics available in Pro and exploration mode', () => { + requests[0].next(usage()); + for (const name of ['Pro', '探索']) { + service.activeModel.set({ name, id: 'test-model', provider: 'openai' }); + fixture.detectChanges(); + expect(text()).toContain('29.5%'); + expect(text()).toContain('90k 缓存 Token'); + } + }); + + it('refreshes running usage while allowing slow reads to finish and stops polling on close', fakeAsync(() => { + running.set(true); + fixture.detectChanges(); + expect(requests.length).toBe(2); + tick(6000); + expect(requests.length).toBe(2); + requests[1].next(usage()); + requests[1].complete(); + tick(3000); + expect(requests.length).toBe(3); + requests[2].next(usage('cache-session', 180000)); + fixture.detectChanges(); + expect(text()).toContain('59%'); + component.isRunInfoOpen.set(false); + fixture.detectChanges(); + expect(requests[2].observed).toBeFalse(); + tick(3000); + expect(requests.length).toBe(3); + })); + + it('cancels old reads and clears the previous task usage when switching tasks or closing', () => { + requests[0].next(usage()); + service.currentSessionId.set('next-session'); + fixture.detectChanges(); + expect(requests[0].observed).toBeFalse(); + expect(component.runUsage()).toBeNull(); + expect(text()).not.toContain('90k 缓存 Token'); + requests[1].error(new Error('offline')); + fixture.detectChanges(); + expect(text()).toContain('读取失败'); + component.isRunInfoOpen.set(false); + fixture.detectChanges(); + expect(overlay.getContainerElement().querySelector('.run-info-dropdown')).toBeNull(); + }); +}); 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 fb3f763..9231dfd 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 @@ -245,6 +245,21 @@ limitations under the License. } +
+ 缓存命中率 + @if (runInfoCachePercent() !== null) { + + {{ runInfoCachePercent() }}% + {{ formatRunTokens(runUsage()?.cached_tokens) }} 缓存 Token + + } @else { + {{ runUsageLoading() ? '正在读取…' : runUsageError() ? '读取失败' : '—' }} + } +
+ @if (runUsageError() && runUsage()) { +
用量刷新失败,显示上次统计。
+ } +
上下文 diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts index 2b908df..3e74a73 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts @@ -31,6 +31,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { HttpClient } from '@angular/common/http'; +import { Subscription } from 'rxjs'; import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service'; import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model'; import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component'; @@ -41,6 +42,7 @@ import { OverlayModule, ConnectedPosition } from '@angular/cdk/overlay'; import { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model'; import { contextPercent, + cachePercent, contextWindow, runMode, formatCompactTokens, @@ -304,6 +306,9 @@ export class AgentStreamComponent implements AfterViewInit { // usage, executor context share and the Pro tuning the run started with. public isRunInfoOpen = signal(false); public runUsage = signal(null); + public runUsageLoading = signal(false); + public runUsageError = signal(false); + public runInfoCachePercent = computed(() => cachePercent(this.runUsage()?.cached_tokens, this.runUsage()?.prompt_tokens)); private runInfoClock = signal(Date.now()); public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel())); @@ -611,10 +616,17 @@ export class AgentStreamComponent implements AfterViewInit { if (untracked(() => this.runUsage())?.session_id !== sessionId) { this.runUsage.set(null); } - this.loadRunUsage(sessionId); - if (!running) return; - const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000); - onCleanup(() => clearInterval(usageInterval)); + let request: Subscription | null = null; + const refresh = () => { + if (request && !request.closed) return; + request = this.loadRunUsage(sessionId); + }; + refresh(); + const usageInterval = running ? setInterval(refresh, 3000) : null; + onCleanup(() => { + if (usageInterval !== null) clearInterval(usageInterval); + request?.unsubscribe(); + }); }); effect((onCleanup) => { if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return; @@ -923,13 +935,20 @@ export class AgentStreamComponent implements AfterViewInit { } } - private loadRunUsage(sessionId: string): void { - this.agentService.getSessionUsage(sessionId).subscribe({ + private loadRunUsage(sessionId: string): Subscription { + this.runUsageLoading.set(true); + this.runUsageError.set(false); + return this.agentService.getSessionUsage(sessionId).subscribe({ next: (usage) => { if (this.agentService.currentSessionId() !== sessionId) return; + this.runUsageLoading.set(false); this.runUsage.set(usage); }, - error: (err) => console.error('Failed to load session usage:', err) + error: () => { + if (this.agentService.currentSessionId() !== sessionId) return; + this.runUsageLoading.set(false); + this.runUsageError.set(true); + } }); } diff --git a/apps/showcase_ui/src/app/utils/run-info.util.spec.ts b/apps/showcase_ui/src/app/utils/run-info.util.spec.ts index 4926968..1c98815 100644 --- a/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +++ b/apps/showcase_ui/src/app/utils/run-info.util.spec.ts @@ -1,5 +1,6 @@ import { contextPercent, + cachePercent, runMode, formatCompactTokens, formatElapsed, @@ -9,6 +10,20 @@ import { } from './run-info.util'; describe('run-info.util', () => { + describe('cachePercent', () => { + it('uses cumulative cached input divided by cumulative input, independent of output', () => { + // Calls: 90/100 and 0/900 -> 9%, rather than an average of 45%. + expect(cachePercent(90, 1000)).toBe(9); + expect(cachePercent(90000, 305000)).toBe(29.5); + expect(cachePercent(0, 100)).toBe(0); + expect(cachePercent(100, 100)).toBe(100); + }); + it('keeps missing, empty and invalid usage distinct from a measured zero', () => { + for (const [cached, prompt] of [[0, 0], [null, 100], [undefined, 100], [0, null], [NaN, 100], [Infinity, 100], [1, Infinity], [-1, 100], [101, 100]]) { + expect(cachePercent(cached, prompt)).toBeNull(); + } + }); + }); describe('formatElapsed', () => { it('formats seconds, minutes and hours with zero padding', () => { expect(formatElapsed(0)).toBe('0s'); diff --git a/apps/showcase_ui/src/app/utils/run-info.util.ts b/apps/showcase_ui/src/app/utils/run-info.util.ts index 84c7ac3..6409df3 100644 --- a/apps/showcase_ui/src/app/utils/run-info.util.ts +++ b/apps/showcase_ui/src/app/utils/run-info.util.ts @@ -80,6 +80,14 @@ export function contextPercent( return Math.min(100, Math.round(pct * 10) / 10); } +/** Session-wide token-weighted cache share; cached input is part of total input. */ +export function cachePercent(cachedTokens: number | null | undefined, promptTokens: number | null | undefined): number | null { + if (typeof cachedTokens !== 'number' || typeof promptTokens !== 'number' || + !Number.isFinite(cachedTokens) || !Number.isFinite(promptTokens) || + promptTokens <= 0 || cachedTokens < 0 || cachedTokens > promptTokens) return null; + return Math.round(cachedTokens / promptTokens * 1000) / 10; +} + /** * Seconds a session has been (or was) running. * diff --git a/artemis/llm/cache_affinity.py b/artemis/llm/cache_affinity.py index 4f096b1..3dd857e 100644 --- a/artemis/llm/cache_affinity.py +++ b/artemis/llm/cache_affinity.py @@ -16,8 +16,18 @@ def cache_affinity_options( 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). + A caller-supplied routing header also supplies a missing body key, so gateways + reading different key sources agree on the same affinity whenever possible. """ result = dict(options or {}) + existing = result.get("extra_headers") + if "prompt_cache_key" not in result and isinstance(existing, Mapping): + names = {name.lower(): value for name, value in existing.items()} + for name in ("thread-id", "thread_id", "session-id", "session_id"): + value = names.get(name) + if isinstance(value, str) and re.fullmatch(r"[-0-9A-Za-z._:]{1,128}", value): + result["prompt_cache_key"] = value + break 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): @@ -28,7 +38,6 @@ def cache_affinity_options( 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 {}) diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 11ca795..4d6af2a 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -151,7 +151,17 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶 以上为适配后消息的本地指纹比较,不是网关最终序列化后的 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 客户端,避免并发任务互相覆盖。显式传入的缓存键及会话请求头优先。 +执行模型、视觉摘要和历史摘要的 Responses 请求均按“探索会话(普通任务用任务 ID)+角色+模型”生成稳定的匿名 `prompt_cache_key`,并在 `Thread-Id`、`Session-Id` 请求头中发送相同值。标识符合 `^[-0-9A-Za-z._:]{1,128}$`;暂停继续时保持稳定,不同任务或角色隔离。网关可从头部或 JSON 字段提取路由键,不依赖特定 User-Agent,也不会伪装为 Codex。标识在每次调用时设置,不写入共享 HTTP 客户端,避免并发任务互相覆盖。显式传入的缓存键及会话请求头优先。仅指定会话请求头时,正文中缺省的缓存键依次沿用有效的 `thread-id`、`thread_id`、`session-id` 或 `session_id`(头名称不区分大小写);补齐缺少的另一种会话头,但不添加同义别名。调用方同时显式指定不同的键时保留原值,应自行保证一致。 + +#### 网关亲和规则 + +网关透传 `Thread-Id` / `Thread_id`、`Session-Id` / `Session_id`,并从这些请求头或 JSON 的 `prompt_cache_key` 提取亲和键,即可覆盖 ARTEMIS 默认请求。默认这三个值相同,无需重复添加下划线头或 `X-Codex-Window-Id`。`pass_headers` 只负责透传,选中渠道和模型前缀缓存是否命中仍取决于服务端实现;不能仅凭头部存在宣称命中。 + +`X-Client-Request-Id` 是每次尝试独立的诊断编号,可继续透传,但不要加入缓存亲和键来源。`User-Agent`、`Originator` 和 Codex 专用头也不应替代 ARTEMIS 的会话键。保留其他客户端需要的透传项即可;本版不修改生产网关规则。 + +执行模式弹窗显示本任务所有已记录模型调用的缓存 Token 和加权命中率:`累计 cached_tokens ÷ 累计 prompt_tokens`。缓存输入已包含在输入总量中,不能再相加;不平均每次调用的百分比,也不把命中请求数当成 token 命中率。打开弹窗后运行中每 3 秒更新,历史任务读取已保存统计;没有有效输入用量时显示 `—`,请求失败有明确提示。统计以服务端上报为准,未上报的缓存量按 0 汇总,旧任务漏记的数据不会回填。 + +模型缓存还要求可复用的上下文前缀,路由稳定不能保证命中或节费。参考 [OpenAI 官方缓存说明](https://developers.openai.com/api/docs/guides/prompt-caching)。 有请求诊断的调用同时发送 `X-Client-Request-Id`,默认与 trace 内的 `request_id` 一致,便于对照 API 日志。`request_sent_at` 表示实际发送时间;`routing_header_fingerprints` 只记录发送的会话头的哈希,`client_request_id_matches` 表示关联 ID 是否一致。不记录认证头或其他请求头正文。稳定标识提供路由亲和条件,实际命中仍取决于前缀和上游缓存。 diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md new file mode 100644 index 0000000..d42eeab --- /dev/null +++ b/docs/releases/2.5.0.md @@ -0,0 +1,46 @@ +# ARTEMIS 2.5.0 + +本版汇总正式 2.4.5 之后的动作执行、模型流式调用和缓存可观测性改进。Flash 和 Pro 可在一次调用中完成需要紧接着执行的界面操作,异常参数流有更准确的诊断与收尾;执行模式弹窗增加缓存 Token 与加权命中率。 + +## 通用动作序列 + +- 新增 `action_sequence`,由 AI 组合点击、等待、拖动和临时控件准备步骤,可用于自动隐藏面板等多种界面,不绑定某个应用或某个缩放按钮。 +- 新增 `ensure_visible`:使用实际观察到的控件 resource ID 判断面板是否已展开;未展开时点击一次入口,并在有限时间内等待目标出现。准备与后续操作留在同一次调用中,减少模型往返超过控件显示时间的问题。 +- 序列点击可按实际 resource ID 重新解析当前边界;缺失或存在歧义时拒绝输入。重复点击可在一个成员内连续完成,每次重新检查目标。仍支持明确声明坐标空间的坐标点击。 +- 输入前校验整组成员,受现有快速动作上限约束。执行遇到首个失败即停止,回执保留已完成、跳过及未执行成员和已完成点击次数;部分执行后不能盲目重放整组动作。 + +## 滑块与时间轴拖动 + +- 新增 `drag`,明确区分截图原始像素和 0..1000 归一化坐标,支持拖动持续时间及面板可见性前置检查。 +- 可直接提供起终点,也可使用当前截图中两个已观察的数值刻度计算目标位置;分别支持移动滑块和在固定指示线下平移刻度。边界、无效刻度、零位移和不可达目标在输入前拒绝。 +- Flash、Pro、检查与验收流程同步认识新动作,截图标注和历史摘要保留实际动作回执及坐标空间,避免把跳过或拒绝的动作当成执行成功。 +- 对最大值、最小值和时间定位加强证据要求:手柄接近端点、事件卡片时间或一次未变化的截图不能独立证明已到极限,需核对实际输入回执和最终刻度或原生数值。 + +刻度校准依赖当前画面的测量,不能替代业务结果验证;惯性滚动、界面重绘和应用自身限制仍可能影响最终位置。 + +## 模型参数流与取消处理 + +- 将 `action_sequence`、`drag` 接入小型内置动作的 16384 字符参数流保护。异常参数不进入设备输入,沿用有限的新响应重试及备用端点策略;不会因此创建全局模型暂停。 +- 分批聚合流式片段,减少半截 JSON 反复解析的开销;参数检查、诊断和界面输出仍逐片段进行,原异步历史压缩路径保留。最终完整性与 JSON 校验不放宽。 +- 参数超限后先关闭 HTTP 流再等待重试;低于长度上限但一直未完成的参数,仍由请求超时、任务超时和用户停止中断。关闭有独立等待上限,关闭失败留有记录,不把用户停止变成可重试超时。 +- 在现有请求诊断中增加原始 Responses SSE 与 SDK 两个边界的有界统计:参数空白、增量长度、连续相同增量、事件序号跳变和非递增、取消阶段、关闭结果以及各阶段耗时。临时帧在关闭时释放,日志不保存参数正文。 +- 记录实际请求与响应回显的工具约束,区分 `strict` 省略、false 和 true;本地 Pydantic 严格校验不等于上游严格生成。不会直接将所有工具改成 strict=true,也不将通道可能忽略的输出 token 上限当成可靠保障。 + +已有异常样本在官方上游返回时就持续增长;本版修复本地消费和收尾问题,补齐诊断与保护,尚不能宣称模型或上游服务的参数膨胀已根治。详见[参数流诊断说明](../llm-stream-diagnostics.md)。 + +## 缓存路由与运行信息 + +- 默认请求继续为同一会话、角色与模型发送一致的 `prompt_cache_key`、`Thread-Id` 和 `Session-Id`。仅显式传入会话请求头时,未指定的正文缓存键和另一种会话头沿用该值,兼容大小写及下划线写法;不修改共享 HTTP 客户端的默认头,保留显式覆盖。 +- 执行模式弹窗显示本任务累计缓存 Token 和按输入 Token 加权的命中率,支持 Flash、Pro、探索及历史任务。运行中每 3 秒刷新;加载、失败与无有效用量分别显示。缓存 Token 包含在输入总量中,不重复计入合计。 +- 补充[网关亲和规则说明](../ai-automation.md#网关亲和规则):会话键可用于选中渠道,请求诊断编号不应作为缓存亲和键。统计以实际服务端用量为准;稳定路由不等于已命中缓存,本版未修改生产网关配置或承诺固定收益。 + +## 兼容性与验证范围 + +- 新动作需要 ARTEMIS 2.5.0;原有动作与插件清单保持兼容。2.4.5 的插件开发 SDK 随主程序继续提供,远程 `artemis-client`、市场插件和第三方依赖本次不升版。 +- 升级主程序不会自动更新或授权插件,不改写已保存的模型、接口、重试或功能配置。 +- Python 默认回归、质量检查、前端回归及最终 ZIP 在独立空 npm 缓存下的官方源和国内镜像安装、生产构建结果,随附件 `INSTALL-VERIFICATION.md` 提供,记录实际运行时、源码提交及 ZIP SHA256。 +- 本次发布不新增真实设备或付费模型测试。此前实测中保留了未通过或无法判定的任务,工具调用成功不等于业务稳定性验收通过;不以一次成功宣称自动隐藏控件问题已全部解决。 + +## 升级 + +已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat`,然后重启 ARTEMIS;Codex 用户同时重新加载 MCP。新安装下载 `artemis-2.5.0-source.zip`,按 `SHA256SUMS.txt` 校验后启动,不直接覆盖旧安装目录。 diff --git a/pyproject.toml b/pyproject.toml index 4d9901c..59b2604 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "2.4.5" +version = "2.5.0" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/tests/unit/test_cache_affinity.py b/tests/unit/test_cache_affinity.py index 368da4b..495fde6 100644 --- a/tests/unit/test_cache_affinity.py +++ b/tests/unit/test_cache_affinity.py @@ -3,6 +3,8 @@ from types import SimpleNamespace from uuid import uuid4 import re +import pytest + from artemis.llm.cache_affinity import cache_affinity_options @@ -53,3 +55,35 @@ def test_concurrent_calls_do_not_share_or_mutate_task_headers(): ) assert results[0]["extra_headers"]["Thread-Id"] != results[1]["extra_headers"]["Thread-Id"] assert shared == {"extra_headers": {"X-Test": "shared"}} + + +@pytest.mark.parametrize("name", ["Thread-Id", "THREAD_ID", "session-id", "Session_id"]) +def test_header_only_override_aligns_body_and_missing_headers(name): + headers = {name: "caller-conversation", "X-Test": "untouched"} + actual = cache_affinity_options(context("task"), "flash", "test", {"extra_headers": headers}) + assert actual["prompt_cache_key"] == "caller-conversation" + routing = { + key.lower().replace("_", "-"): value for key, value in actual["extra_headers"].items() + } + assert routing["thread-id"] == routing["session-id"] == actual["prompt_cache_key"] + assert headers == {name: "caller-conversation", "X-Test": "untouched"} + assert cache_affinity_options(None, None, None, {"extra_headers": headers}) == actual + + +def test_explicit_body_and_header_options_are_not_overwritten(): + options = { + "prompt_cache_key": "body-override", + "extra_headers": {"thread_id": "header-override"}, + } + actual = cache_affinity_options(context("task"), "flash", "test", options) + assert actual["prompt_cache_key"] == "body-override" + assert actual["extra_headers"]["thread_id"] == "header-override" + assert "Thread-Id" not in actual["extra_headers"] + + +@pytest.mark.parametrize("value", [None, "", "unsafe\r\nheader", "a" * 129]) +def test_invalid_header_is_not_promoted_to_body(value): + actual = cache_affinity_options( + context("task"), "flash", "test", {"extra_headers": {"Thread-Id": value}} + ) + assert actual["prompt_cache_key"].startswith("artemis:") diff --git a/tests/unit/test_openai_responses.py b/tests/unit/test_openai_responses.py index 04af4a9..5366fc7 100644 --- a/tests/unit/test_openai_responses.py +++ b/tests/unit/test_openai_responses.py @@ -363,6 +363,24 @@ async def test_cache_key_is_stable_for_exploration_but_separates_roles( assert requests[0].headers["x-client-request-id"] != requests[1].headers["x-client-request-id"] +@pytest.mark.asyncio +@pytest.mark.parametrize("header", ["Thread-Id", "thread_id", "SESSION-ID", "Session_id"]) +async def test_caller_affinity_header_reaches_wire_as_body_key(transport_model, header): + model, requests, _ = transport_model + wrapper = RobustChatModelWrapper(model, endpoint=ModelEndpoint(model_name="test-model")) + headers = {header: "caller-affinity"} + for _ in range(2): + await wrapper.ainvoke([HumanMessage(content="shared prefix")], extra_headers=headers) + for request in requests: + payload = json.loads(request.content) + assert payload["prompt_cache_key"] == "caller-affinity" + routing = {name.lower().replace("_", "-"): value for name, value in request.headers.items()} + assert routing["thread-id"] == routing["session-id"] == "caller-affinity" + assert "extra_headers" not in payload + assert requests[0].headers["x-client-request-id"] != requests[1].headers["x-client-request-id"] + assert headers == {header: "caller-affinity"} + + @pytest.mark.asyncio async def test_auxiliary_summary_calls_send_role_scoped_affinity_on_responses(transport_model): from types import SimpleNamespace diff --git a/uv.lock b/uv.lock index 84d448a..2b60ab3 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ wheels = [ [[package]] name = "artemis" -version = "2.4.5" +version = "2.5.0" source = { editable = "." } dependencies = [ { name = "adbutils" },