feat: add cache routing alignment and usage visibility for 2.5.0
This commit is contained in:
1 parent
7c0c2b59ca
commit
7831bf34db
15 files changed
+316
-15
No files matched your search
@@ -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 与插件继续独立版本化;升级主程序不会自动更新或授权插件。
|
||||
|
||||
## 主要功能
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 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-line">
|
||||
<span class="run-info-label">上下文</span>
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
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());
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
+11
-1
@@ -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 是否一致。不记录认证头或其他请求头正文。稳定标识提供路由亲和条件,实际命中仍取决于前缀和上游缓存。
|
||||
|
||||
|
||||
@@ -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
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "2.4.5"
|
||||
version = "2.5.0"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -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:")
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user