feat: add cache routing alignment and usage visibility for 2.5.0

This commit is contained in:
chenmingxuan committed 2026-11-02 17:15:59 +08:00
1 parent 7c0c2b59ca
commit 3dd96078db
15 files changed
+316 -15

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 基于 [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 与插件继续独立版本化;升级主程序不会自动更新或授权插件。
## 主要功能 ## 主要功能
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.5", "version": "2.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.5", "version": "2.5.0",
"dependencies": { "dependencies": {
"@angular/cdk": "^22.2.4", "@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3", "@angular/common": "^22.2.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "artemis-showcase-ui", "name": "artemis-showcase-ui",
"version": "2.4.5", "version": "2.5.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -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<AgentStreamComponent>;
let component: AgentStreamComponent;
let service: AgentService;
let overlay: OverlayContainer;
let requests: Subject<SessionUsage>[];
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<SessionUsage>();
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();
});
});
@@ -245,6 +245,21 @@ limitations under the License.
} }
</div> </div>
<div class="run-info-row" title="本任务累计缓存输入 ÷ 累计输入,包含所有模型调用;缓存 Token 已计入输入总量。未上报的缓存输入按 0 统计。">
<span class="run-info-label">缓存命中率</span>
@if (runInfoCachePercent() !== null) {
<span class="run-info-value">
{{ runInfoCachePercent() }}%
<span class="run-info-sub">{{ formatRunTokens(runUsage()?.cached_tokens) }} 缓存 Token</span>
</span>
} @else {
<span class="run-info-value is-muted">{{ runUsageLoading() ? '正在读取…' : runUsageError() ? '读取失败' : '—' }}</span>
}
</div>
@if (runUsageError() && runUsage()) {
<div class="run-info-note" role="status">用量刷新失败,显示上次统计。</div>
}
<div class="run-info-row is-stacked"> <div class="run-info-row is-stacked">
<div class="run-info-line"> <div class="run-info-line">
<span class="run-info-label">上下文</span> <span class="run-info-label">上下文</span>
@@ -31,6 +31,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service'; import { AgentService, GuidanceEvent, StartupProgressEvent } from '../../services/agent.service';
import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model'; import { Session, ModelInfo, SessionUsage } from '../../core/models/session.model';
import { TestResultBadgeComponent, TEST_RESULT_LABELS } from '../test-result-badge/test-result-badge.component'; 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 { StepBlock, PhaseBlock, StepEvent, ActionParam, CheckerResult, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE } from '../../core/models/stream.model';
import { import {
contextPercent, contextPercent,
cachePercent,
contextWindow, contextWindow,
runMode, runMode,
formatCompactTokens, formatCompactTokens,
@@ -304,6 +306,9 @@ export class AgentStreamComponent implements AfterViewInit {
// usage, executor context share and the Pro tuning the run started with. // usage, executor context share and the Pro tuning the run started with.
public isRunInfoOpen = signal<boolean>(false); public isRunInfoOpen = signal<boolean>(false);
public runUsage = signal<SessionUsage | null>(null); public runUsage = signal<SessionUsage | null>(null);
public runUsageLoading = signal(false);
public runUsageError = signal(false);
public runInfoCachePercent = computed(() => cachePercent(this.runUsage()?.cached_tokens, this.runUsage()?.prompt_tokens));
private runInfoClock = signal<number>(Date.now()); private runInfoClock = signal<number>(Date.now());
public runInfoMode = computed(() => runMode(this.agentService.currentSession(), this.agentService.viewedModel())); 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) { if (untracked(() => this.runUsage())?.session_id !== sessionId) {
this.runUsage.set(null); this.runUsage.set(null);
} }
this.loadRunUsage(sessionId); let request: Subscription | null = null;
if (!running) return; const refresh = () => {
const usageInterval = setInterval(() => this.loadRunUsage(sessionId), 3000); if (request && !request.closed) return;
onCleanup(() => clearInterval(usageInterval)); request = this.loadRunUsage(sessionId);
};
refresh();
const usageInterval = running ? setInterval(refresh, 3000) : null;
onCleanup(() => {
if (usageInterval !== null) clearInterval(usageInterval);
request?.unsubscribe();
});
}); });
effect((onCleanup) => { effect((onCleanup) => {
if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return; if (!this.isRunInfoOpen() || !this.agentService.isCurrentSessionRunning()) return;
@@ -923,13 +935,20 @@ export class AgentStreamComponent implements AfterViewInit {
} }
} }
private loadRunUsage(sessionId: string): void { private loadRunUsage(sessionId: string): Subscription {
this.agentService.getSessionUsage(sessionId).subscribe({ this.runUsageLoading.set(true);
this.runUsageError.set(false);
return this.agentService.getSessionUsage(sessionId).subscribe({
next: (usage) => { next: (usage) => {
if (this.agentService.currentSessionId() !== sessionId) return; if (this.agentService.currentSessionId() !== sessionId) return;
this.runUsageLoading.set(false);
this.runUsage.set(usage); 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);
}
}); });
} }
@@ -1,5 +1,6 @@
import { import {
contextPercent, contextPercent,
cachePercent,
runMode, runMode,
formatCompactTokens, formatCompactTokens,
formatElapsed, formatElapsed,
@@ -9,6 +10,20 @@ import {
} from './run-info.util'; } from './run-info.util';
describe('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', () => { describe('formatElapsed', () => {
it('formats seconds, minutes and hours with zero padding', () => { it('formats seconds, minutes and hours with zero padding', () => {
expect(formatElapsed(0)).toBe('0s'); expect(formatElapsed(0)).toBe('0s');
@@ -80,6 +80,14 @@ export function contextPercent(
return Math.min(100, Math.round(pct * 10) / 10); 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. * Seconds a session has been (or was) running.
* *
+10 -1
View File
@@ -16,8 +16,18 @@ def cache_affinity_options(
its own cache scope so background summaries do not compete with the agent. 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. Set hints per call: raw models/HTTP clients may be shared by concurrent tasks.
Explicit caller keys and headers take precedence (including underscore aliases). 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 {}) 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) engine = getattr(ctx, "data_engine", None)
session = getattr(ctx, "exploration_id", None) or getattr(engine, "current_session_id", None) session = getattr(ctx, "exploration_id", None) or getattr(engine, "current_session_id", None)
if isinstance(session, (str, UUID)) and str(session): if isinstance(session, (str, UUID)) and str(session):
@@ -28,7 +38,6 @@ def cache_affinity_options(
key = result.get("prompt_cache_key") key = result.get("prompt_cache_key")
if not isinstance(key, str) or not re.fullmatch(r"[-0-9A-Za-z._:]{1,128}", key): if not isinstance(key, str) or not re.fullmatch(r"[-0-9A-Za-z._:]{1,128}", key):
return result return result
existing = result.get("extra_headers")
if existing is not None and not isinstance(existing, Mapping): if existing is not None and not isinstance(existing, Mapping):
return result return result
headers = dict(existing or {}) headers = dict(existing or {})
+11 -1
View File
@@ -151,7 +151,17 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
以上为适配后消息的本地指纹比较,不是网关最终序列化后的 token 前缀,也不是命中率。诊断仅持有哈希,不额外保存提示词、图片正文或凭据。实际缓存率按上游返回的 `cached_tokens / input_tokens` 加权统计,usage 缺失不能当作零命中。首次请求、阶段压缩、模型/工具切换可能降低缓存率;网关的路由、缓存保留时间和实现也会影响结果。该实现使用完整多轮消息,不依赖网关支持 `previous_response_id`,也不承诺固定命中率。 以上为适配后消息的本地指纹比较,不是网关最终序列化后的 token 前缀,也不是命中率。诊断仅持有哈希,不额外保存提示词、图片正文或凭据。实际缓存率按上游返回的 `cached_tokens / input_tokens` 加权统计,usage 缺失不能当作零命中。首次请求、阶段压缩、模型/工具切换可能降低缓存率;网关的路由、缓存保留时间和实现也会影响结果。该实现使用完整多轮消息,不依赖网关支持 `previous_response_id`,也不承诺固定命中率。
执行模型、视觉摘要和历史摘要的 Responses 请求均按“探索会话(普通任务用任务 ID)+角色+模型”生成稳定的匿名 `prompt_cache_key`,并在 `Thread-Id`、`Session-Id` 请求头中发送相同值。标识符合 `^[-0-9A-Za-z._:]{1,128}$`;暂停继续时保持稳定,不同任务或角色隔离。网关可从头部或 JSON 字段提取路由键,不依赖特定 User-Agent,也不会伪装为 Codex。标识在每次调用时设置,不写入共享 HTTP 客户端,避免并发任务互相覆盖。显式传入的缓存键及会话请求头优先。 执行模型、视觉摘要和历史摘要的 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 是否一致。不记录认证头或其他请求头正文。稳定标识提供路由亲和条件,实际命中仍取决于前缀和上游缓存。 有请求诊断的调用同时发送 `X-Client-Request-Id`,默认与 trace 内的 `request_id` 一致,便于对照 API 日志。`request_sent_at` 表示实际发送时间;`routing_header_fingerprints` 只记录发送的会话头的哈希,`client_request_id_matches` 表示关联 ID 是否一致。不记录认证头或其他请求头正文。稳定标识提供路由亲和条件,实际命中仍取决于前缀和上游缓存。
+46
View File
@@ -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` 校验后启动,不直接覆盖旧安装目录。
+1 -1
View File
@@ -14,7 +14,7 @@
[project] [project]
name = "artemis" name = "artemis"
version = "2.4.5" version = "2.5.0"
description = "Autonomous AI Assistant for Mobile Automation" description = "Autonomous AI Assistant for Mobile Automation"
authors = [ authors = [
+34
View File
@@ -3,6 +3,8 @@ from types import SimpleNamespace
from uuid import uuid4 from uuid import uuid4
import re import re
import pytest
from artemis.llm.cache_affinity import cache_affinity_options 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 results[0]["extra_headers"]["Thread-Id"] != results[1]["extra_headers"]["Thread-Id"]
assert shared == {"extra_headers": {"X-Test": "shared"}} 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:")
+18
View File
@@ -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"] 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 @pytest.mark.asyncio
async def test_auxiliary_summary_calls_send_role_scoped_affinity_on_responses(transport_model): async def test_auxiliary_summary_calls_send_role_scoped_affinity_on_responses(transport_model):
from types import SimpleNamespace from types import SimpleNamespace
Generated
+1 -1
View File
@@ -248,7 +248,7 @@ wheels = [
[[package]] [[package]]
name = "artemis" name = "artemis"
version = "2.4.5" version = "2.5.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "adbutils" }, { name = "adbutils" },