feat(context): 平台构建上下文与检查点(A3.8)

按实施设计 1.3/1.5:上下文由平台构建,不把整段会话塞给模型。新增 server/app/context-builder.ts——按预算保留最近若干轮 + 钉住引用,把更早的消息折叠成结构化摘要(每条带来源消息 id),工具执行只以引用进摘要(能力、状态、参数摘要、executionId),原始消息一律不动。新增 context_checkpoints 表与仓储(迁移 v2),每轮结束后落检查点,摘要与来源可查。应用服务在运行时会话首次发送时注入上下文包,同一运行时会话不重复注入(引擎自己有原生上下文时就不需要)。Pi 原生会话目录支持 APS_PI_SESSION_DIR 覆盖,便于验证「引擎无状态、平台补上下文」。验证:构建器 5 条单测 + 跨重启注入集成用例;真模型实测——重启平台并让 Pi 用全新会话目录,问「我刚才让你记住什么」答出「25 分钟(车间换型时间)」。测试 70 → 76。
This commit is contained in:
zh 2026-09-29 10:24:41 +08:00
parent d5df4f5408
commit e4b6051da1
10 changed files with 465 additions and 3 deletions

View File

@ -0,0 +1,66 @@
/** 上下文构建用例(任务 A3.8):预算、折叠、追溯、钉住引用。 */
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildContext } from '@aps/server/app/context-builder';
const message = (id, role, text) => ({ messageId: id, role, ts: 1, text });
test('历史很短时全部保留,不产生摘要', () => {
const messages = [message('m1', 'user', '你好'), message('m2', 'assistant', '你好,有什么可以帮你')];
const result = buildContext({ messages });
assert.ok(result.prefix.includes('【最近的对话】'));
assert.deepEqual(result.includedMessageIds, ['m1', 'm2']);
assert.deepEqual(result.droppedMessageIds, []);
assert.equal(result.summary, '');
});
test('空历史不注入任何前缀', () => {
assert.equal(buildContext({ messages: [] }).prefix, '');
});
test('超预算时老消息折叠进摘要,且能追到原始消息 id', () => {
const messages = [];
for (let index = 0; index < 40; index += 1) {
messages.push(message(`m${index}`, index % 2 === 0 ? 'user' : 'assistant', `第 ${index} 条消息,`.repeat(20)));
}
const result = buildContext({ messages, budget: { maxChars: 1200, recentMessages: 4, summaryMaxChars: 800 } });
assert.ok(result.droppedMessageIds.length > 0, '应有消息被折叠');
assert.equal(result.summarySourceIds.length, result.droppedMessageIds.length, '摘要来源应与折叠集合一致');
assert.ok(result.summary.length > 0);
// 摘要里逐条带来源 id,任何结论都能追回原文
for (const id of result.summarySourceIds.slice(0, 3)) {
assert.ok(result.summary.includes(id), `摘要应包含来源 ${id}`);
}
// 最近若干轮必须原样保留
const lastIds = messages.slice(-4).map((item) => item.messageId);
for (const id of lastIds) assert.ok(result.includedMessageIds.includes(id), `${id} 应保留`);
});
test('工具执行只以引用进摘要,不进历史原文', () => {
const messages = Array.from({ length: 30 }, (_, index) =>
message(`m${index}`, index % 2 === 0 ? 'user' : 'assistant', `内容 ${index} `.repeat(40)),
);
const result = buildContext({
messages,
executions: [
{ capabilityId: 'fake.echo', status: 'succeeded', executionId: 'exec-123', paramsDigest: 'sha256:abc', createdAt: 1 },
],
budget: { maxChars: 900, recentMessages: 2, summaryMaxChars: 600 },
});
assert.ok(result.prefix.includes('【相关工具执行(只给引用,需要细节请调对应能力)】'), result.prefix.slice(0, 200));
assert.ok(result.prefix.includes('exec-123'), '应给出执行号引用');
assert.ok(result.prefix.includes('sha256:abc'), '应给出参数摘要');
assert.ok(!result.prefix.includes('"echo"'), '不应把工具结果原文塞进上下文');
});
test('钉住的消息即使很老也保留', () => {
const messages = Array.from({ length: 30 }, (_, index) =>
message(`m${index}`, index % 2 === 0 ? 'user' : 'assistant', `内容 ${index} `.repeat(40)),
);
const result = buildContext({ messages, pinnedMessageIds: ['m0'], budget: { maxChars: 800, recentMessages: 2, summaryMaxChars: 400 } });
assert.ok(result.includedMessageIds.includes('m0'), '钉住的消息必须保留');
assert.ok(!result.droppedMessageIds.includes('m0'));
});

View File

@ -0,0 +1,104 @@
/**
* 上下文注入用例(任务 A3.8)。
*
* 模拟「平台重启后继续同一会话」:新的引擎进程没有原生上下文,平台必须把
* 「摘要 + 最近若干轮」注入进去,否则模型会失忆。
* 同时锁住两点:同一运行时会话只注入一次;检查点与来源消息 id 落库可查。
*/
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
/** 记录每轮收到的 prompt 文本,便于断言注入了什么 */
function capturingRuntime() {
const prompts = [];
return {
prompts,
runtime: {
name: 'capturing',
toolExecution: 'platform',
async createSession() {
return {
id: `cap-${Math.random().toString(36).slice(2)}`,
toolExecution: 'platform',
async *send(text) {
prompts.push(text);
yield { type: 'text', delta: '收到。' };
yield { type: 'turn_end', reason: 'stop' };
},
resolveToolResult() {},
abort() {},
async dispose() {},
};
},
},
};
}
async function startServer(dbPath, runtime) {
const { startServer: start } = await import('../server/dist/index.js');
return start({ host: '127.0.0.1', port: 0, version: 'test', databasePath: dbPath, engine: 'fake', runtime });
}
const postJson = (base, path, body) =>
fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
async function send(server, sessionId, text, key) {
const response = await postJson(server.url, `/v1/sessions/${sessionId}/messages`, { text, idempotencyKey: key });
assert.equal(response.status, 202);
// 等回合结束(事件流未订阅,这里直接轮询会话)
for (let index = 0; index < 60; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 25));
const detail = await (await fetch(`${server.url}/v1/sessions/${sessionId}`)).json();
if (detail.messages.some((message) => message.role === 'assistant' && message.text === '收到。')) {
const before = detail.messages.filter((message) => message.role === 'assistant' && message.text === '收到。').length;
if (before >= 1) return;
}
}
throw new Error('回合未在预期时间内结束');
}
test('平台重启后把摘要与最近对话注入新引擎进程,且只注入一次', async () => {
const dir = await mkdtemp(join(tmpdir(), 'aps-context-'));
const dbPath = join(dir, 'aps.sqlite');
try {
// 第一个实例:建立会话与历史
const first = capturingRuntime();
const serverA = await startServer(dbPath, first.runtime);
const created = await (await postJson(serverA.url, '/v1/sessions', { scope: 'project', projectId: 'p-default', title: '上下文用例' })).json();
const sessionId = created.session.sessionId;
await send(serverA, sessionId, '记住:车间换型时间是 25 分钟。', 'idem-ctx-0001');
await send(serverA, sessionId, '再说一遍,重点是换型。', 'idem-ctx-0002');
await serverA.close();
// 第二个实例:新引擎进程,平台必须补上下文
const second = capturingRuntime();
const serverB = await startServer(dbPath, second.runtime);
try {
await send(serverB, sessionId, '我刚才让你记住什么?', 'idem-ctx-0003');
assert.equal(second.prompts.length, 1);
const injected = second.prompts[0];
assert.ok(injected.includes('【最近的对话】'), `重启后应注入最近对话:${injected.slice(0, 200)}`);
assert.ok(injected.includes('车间换型时间是 25 分钟'), '注入内容应含重启前的原话');
assert.ok(injected.includes('我刚才让你记住什么?'), '本轮用户输入也要在同一个 prompt 里');
// 同一运行时会话的第二轮不应该再注入(引擎自己有上下文)
await send(serverB, sessionId, '再确认一下。', 'idem-ctx-0004');
assert.equal(second.prompts.length, 2);
assert.ok(!second.prompts[1].includes('【最近的对话】'), '第二轮不应重复注入上下文');
// 检查点落库:摘要来源与最后消息 id 可查
const checkpoint = serverB.service.checkpoint(sessionId);
assert.ok(checkpoint, '检查点应落库');
assert.equal(typeof checkpoint.updatedAt, 'number');
assert.ok(checkpoint.includedMessageIds.length > 0, '应记录保留过哪些消息');
assert.ok(checkpoint.lastMessageId !== null);
} finally {
await serverB.close();
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});

View File

@ -147,7 +147,8 @@ function defaultExtensionPath(): string {
export function createPiRuntime(options: PiRuntimeOptions = {}): AgentRuntime {
const provider = options.provider ?? 'deepseek';
const model = options.model ?? 'deepseek-chat';
const sessionDir = options.sessionDir ?? join(homedir(), '.aps', 'pi-sessions');
// Pi 原生会话目录:引擎原生状态只用于同引擎恢复,可用 APS_PI_SESSION_DIR 换到空目录验证「平台补上下文」
const sessionDir = options.sessionDir ?? process.env.APS_PI_SESSION_DIR ?? join(homedir(), '.aps', 'pi-sessions');
const bridgeDir = options.bridgeDir ?? join(homedir(), '.aps', 'pi-bridge');
const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;

View File

@ -0,0 +1,136 @@
/**
* 上下文构建(任务 A3.8,实施设计 1.3 / 1.5)。
*
* 目标:不把整段会话塞给模型。按预算取「最近若干轮 + 滚动摘要 + 钉住的引用」,
* 工具执行只以引用形式进摘要(能力、状态、参数摘要、executionId),需要细节时再调能力取回。
*
* 三条约束:
* 1. 摘要与原始消息都留在平台:摘要条目带来源消息 id,任何结论都能追回原文
* 2. 构建是确定性的、可测的:这里不调模型(真正的模型摘要等 A4 之后按需替换)
* 3. 引擎只拿到「上下文包」,不拿所有权(架构设计 5.1 固定点:状态归平台)
*/
import type { ChatMessage } from '@aps/contracts';
export interface ContextBudget {
/** 上下文包总字符预算(粗略按字符算,够用且不引入 tokenizer 依赖) */
maxChars: number;
/** 至少保留的最近消息条数 */
recentMessages: number;
/** 摘要部分最多占多少字符 */
summaryMaxChars: number;
/** 摘要里最多引用多少条执行记录 */
recentExecutions: number;
}
export const DEFAULT_CONTEXT_BUDGET: ContextBudget = {
maxChars: 6000,
recentMessages: 6,
summaryMaxChars: 1800,
recentExecutions: 6,
};
/** 执行记录的引用(来自 Core,不进历史原文) */
export interface ExecutionReference {
capabilityId: string;
status: string;
executionId: string;
paramsDigest: string;
createdAt: number;
}
export interface ContextBuildInput {
messages: ChatMessage[];
executions?: ExecutionReference[];
/** 用户钉住的消息:无论多早都保留(界面在 A10.4 提供入口) */
pinnedMessageIds?: string[];
budget?: Partial<ContextBudget>;
}
export interface ContextBuildResult {
/** 注入给引擎的上下文包;空串表示无需注入(例如会话刚开始) */
prefix: string;
/** 存进检查点的摘要(含来源消息 id,便于追溯) */
summary: string;
/** 摘要引用的消息 id */
summarySourceIds: string[];
/** 原样保留的最近消息 id */
includedMessageIds: string[];
/** 因预算被折叠进摘要之前的消息(原文仍在平台,未丢失) */
droppedMessageIds: string[];
estimatedChars: number;
}
const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max)}…(已截断)`);
const roleLabel = (role: ChatMessage['role']): string => (role === 'user' ? '用户' : role === 'assistant' ? '助手' : '系统');
function renderExecution(reference: ExecutionReference): string {
return `- 调用 ${reference.capabilityId} → ${reference.status}(${reference.executionId},参数摘要 ${reference.paramsDigest})`;
}
export function buildContext(input: ContextBuildInput): ContextBuildResult {
const budget: ContextBudget = { ...DEFAULT_CONTEXT_BUDGET, ...(input.budget ?? {}) };
const messages = input.messages;
const pinned = new Set(input.pinnedMessageIds ?? []);
const kept: ChatMessage[] = [];
const dropped: ChatMessage[] = [];
let usedChars = 0;
// 从最近往前收:先保证「最近若干轮」,再按预算继续吃历史
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message) continue;
const cost = message.text.length + 12;
const isRecent = messages.length - index <= budget.recentMessages;
const mustKeep = isRecent || pinned.has(message.messageId);
if (mustKeep || usedChars + cost <= budget.maxChars) {
kept.unshift(message);
usedChars += cost;
} else {
dropped.unshift(message);
}
}
// 摘要:折叠掉的消息取要点(保留前 80 字),附来源 id
const summaryLines: string[] = [];
let summaryChars = 0;
for (const message of dropped) {
const line = `- ${roleLabel(message.role)}(${message.messageId}):${clip(message.text.replace(/\s+/g, ' '), 80)}`;
if (summaryChars + line.length > budget.summaryMaxChars) {
summaryLines.push(`- ……另有 ${dropped.length - summaryLines.length} 条更早的消息,原始内容保留在平台会话记录里`);
break;
}
summaryLines.push(line);
summaryChars += line.length;
}
const executionLines = (input.executions ?? [])
.slice(0, budget.recentExecutions)
.map(renderExecution);
const blocks: string[] = [];
if (summaryLines.length > 0 || executionLines.length > 0) {
blocks.push('【此前对话的摘要】');
blocks.push(...summaryLines);
if (executionLines.length > 0) {
blocks.push('【相关工具执行(只给引用,需要细节请调对应能力)】');
blocks.push(...executionLines);
}
}
if (kept.length > 0) {
blocks.push('【最近的对话】');
for (const message of kept) blocks.push(`${roleLabel(message.role)}:${message.text}`);
}
const prefix = blocks.length === 0 ? '' : `${blocks.join('\n')}\n【以上是背景,以下是用户这次的问题】\n`;
const summary = summaryLines.join('\n');
return {
prefix,
summary,
summarySourceIds: dropped.map((message) => message.messageId),
includedMessageIds: kept.map((message) => message.messageId),
droppedMessageIds: dropped.map((message) => message.messageId),
estimatedChars: prefix.length,
};
}

View File

@ -19,6 +19,8 @@ import {
type CapabilityVisibility,
} from '../core/registry/visibility.js';
import { runTurn } from './turn-runner.js';
import { buildContext, type ContextBudget } from './context-builder.js';
import type { ContextRepository } from '../core/storage/context.js';
import { PLATFORM_SYSTEM_PROMPT } from './system-prompt.js';
import type { Provider } from '@aps/provider-sdk';
import { AppError, badRequest, conflict } from './errors.js';
@ -34,6 +36,10 @@ export interface ApplicationServiceOptions {
sessionRepository?: SessionRepository;
/** 能力可见性规则(A3.5):不传则开发期默认全部可见 */
capabilityVisibility?: CapabilityVisibility;
/** 上下文检查点(A3.8):不传则不落检查点 */
contextRepository?: ContextRepository;
/** 上下文预算覆盖(测试用) */
contextBudget?: Partial<ContextBudget>;
}
export class ApplicationService {
@ -45,6 +51,10 @@ export class ApplicationService {
readonly core: Core;
private readonly runtime: AgentRuntime;
private readonly visibility: CapabilityVisibility;
private readonly contextRepository: ContextRepository | undefined;
private readonly contextBudget: Partial<ContextBudget> | undefined;
/** 已经注入过上下文的运行时会话:同一运行时会话只注入一次(它自己有原生上下文) */
private readonly injectedRuntimeSessions = new Set<string>();
/** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */
private readonly runtimeSessions = new Map<string, RuntimeSession>();
/** 会话 → 工具集指纹:身份或可见能力变了就重建运行时会话 */
@ -54,6 +64,8 @@ export class ApplicationService {
this.version = options.version ?? '0.1.0';
this.sessions = new SessionStore(options.sessionRepository);
this.visibility = options.capabilityVisibility ?? { defaultAllow: true, denyWrite: false, grants: [] };
this.contextRepository = options.contextRepository;
this.contextBudget = options.contextBudget;
this.core = createCore({ registry: this.registry });
this.runtime = options.runtime ?? createFakeRuntime({ planner: plannerToFakeTurn(createRulePlanner()) });
}
@ -63,6 +75,11 @@ export class ApplicationService {
await this.registry.register(provider);
}
/** 上下文检查点(A3.8):平台保存的摘要与来源 */
checkpoint(sessionId: string) {
return this.contextRepository?.get(sessionId);
}
/** 执行记录查询:GET /v1/executions/{id} */
execution(executionId: string) {
return this.core.executions.get(executionId)?.receipt;
@ -220,12 +237,17 @@ export class ApplicationService {
const assistantMessageId = `${messageId}-reply`;
try {
const runtimeSession = await this.runtimeSessionFor(record);
// 上下文由平台构建:新起的运行时会话要带上「摘要 + 最近若干轮」,已有原生上下文的会话不重复注入
const context = this.buildTurnContext(record, messageId);
const shouldInject = !this.injectedRuntimeSessions.has(runtimeSession.id);
this.injectedRuntimeSessions.add(runtimeSession.id);
const promptText = shouldInject && context.prefix !== '' ? `${context.prefix}${text}` : text;
const result = await runTurn({
runtimeSession,
sessionId,
runId,
assistantMessageId,
text,
text: promptText,
signal,
publish: (event) => {
record.bus.publish(event);
@ -236,6 +258,16 @@ export class ApplicationService {
if (result.text !== '') {
this.sessions.appendMessage(sessionId, { messageId: assistantMessageId, role: 'assistant', ts: Date.now(), text: result.text });
}
// 检查点:把这次构建出的摘要与来源落库,摘要与原始消息都保留(实施设计 1.3)
this.contextRepository?.save({
sessionId,
summary: context.summary,
summarySourceIds: context.summarySourceIds,
includedMessageIds: context.includedMessageIds,
lastMessageId: messageId,
pinned: [],
estimatedChars: context.estimatedChars,
});
} catch (error) {
record.bus.publish({
sessionId,
@ -249,6 +281,23 @@ export class ApplicationService {
}
}
/** 构建这一轮要注入的上下文(排除刚写入的当前用户消息) */
private buildTurnContext(record: SessionRecord, currentMessageId: string) {
const history = record.messages.filter((message) => message.messageId !== currentMessageId);
const executions = this.core.executions.listBySession(record.summary.sessionId, 6).map((item) => ({
capabilityId: item.capabilityId,
status: item.status,
executionId: item.executionId,
paramsDigest: item.paramsDigest,
createdAt: item.createdAt,
}));
return buildContext({
messages: history,
executions,
...(this.contextBudget === undefined ? {} : { budget: this.contextBudget }),
});
}
/** 取(或创建)平台会话对应的运行时会话:工具来自能力目录,历史来自平台记录 */
private async runtimeSessionFor(record: SessionRecord): Promise<RuntimeSession> {
const sessionId = record.summary.sessionId;

View File

@ -64,6 +64,14 @@ export class ExecutionStore {
return executionId ? this.records.get(executionId) : undefined;
}
/** 某个会话最近的执行记录(上下文摘要里引用工具结果用) */
listBySession(sessionId: string, limit = 10): ExecutionRecord[] {
return [...this.records.values()]
.filter((record) => record.sessionId === sessionId)
.sort((a, b) => b.createdAt - a.createdAt)
.slice(0, limit);
}
list(limit = 50): ExecutionRecord[] {
return [...this.records.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
}

View File

@ -0,0 +1,78 @@
/** 上下文检查点(A3.8):平台保存「发给模型的上下文」的摘要与来源,权威在平台(实施设计 1.5) */
import type { Database } from './db.js';
export interface ContextCheckpoint {
sessionId: string;
summary: string;
summarySourceIds: string[];
includedMessageIds: string[];
lastMessageId: string | null;
pinned: string[];
estimatedChars: number;
updatedAt: number;
}
interface CheckpointRow {
session_id: string;
summary: string;
summary_source_ids: string;
included_message_ids: string;
last_message_id: string | null;
pinned: string;
estimated_chars: number;
updated_at: number;
}
const parseIds = (raw: string): string[] => {
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : [];
} catch {
return [];
}
};
export class ContextRepository {
constructor(private readonly db: Database) {}
save(checkpoint: Omit<ContextCheckpoint, 'updatedAt'>): void {
this.db
.prepare(
`insert into context_checkpoints (session_id, summary, summary_source_ids, included_message_ids, last_message_id, pinned, estimated_chars, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
on conflict(session_id) do update set
summary = excluded.summary,
summary_source_ids = excluded.summary_source_ids,
included_message_ids = excluded.included_message_ids,
last_message_id = excluded.last_message_id,
pinned = excluded.pinned,
estimated_chars = excluded.estimated_chars,
updated_at = excluded.updated_at`,
)
.run(
checkpoint.sessionId,
checkpoint.summary,
JSON.stringify(checkpoint.summarySourceIds),
JSON.stringify(checkpoint.includedMessageIds),
checkpoint.lastMessageId,
JSON.stringify(checkpoint.pinned),
checkpoint.estimatedChars,
Date.now(),
);
}
get(sessionId: string): ContextCheckpoint | undefined {
const row = this.db.prepare('select * from context_checkpoints where session_id = ?').get(sessionId) as unknown as CheckpointRow | undefined;
if (!row) return undefined;
return {
sessionId: row.session_id,
summary: row.summary,
summarySourceIds: parseIds(row.summary_source_ids),
includedMessageIds: parseIds(row.included_message_ids),
lastMessageId: row.last_message_id,
pinned: parseIds(row.pinned),
estimatedChars: row.estimated_chars,
updatedAt: row.updated_at,
};
}
}

View File

@ -52,8 +52,22 @@ const MIGRATIONS: Array<{ version: number; sql: string }> = [
);
`,
},
{
version: 2,
sql: `
create table if not exists context_checkpoints (
session_id text primary key,
summary text not null,
summary_source_ids text not null,
included_message_ids text not null,
last_message_id text,
pinned text not null default '[]',
estimated_chars integer not null default 0,
updated_at integer not null
);
`,
},
];
export function openDatabase(path: string): Database {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path);

View File

@ -12,6 +12,7 @@ import { loadConfig } from './config.js';
import type { CapabilityVisibility } from './core/registry/visibility.js';
import { openDatabase, type Database } from './core/storage/db.js';
import { SessionRepository } from './core/storage/sessions.js';
import { ContextRepository } from './core/storage/context.js';
import { ApplicationService } from './app/service.js';
import { createHttpServer } from './entry/http.js';
@ -56,6 +57,7 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
runtime,
sessionRepository: new SessionRepository(database),
capabilityVisibility: options.capabilityVisibility ?? config.capabilities,
contextRepository: new ContextRepository(database),
});
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
if (process.env.APS_DEV_PROVIDER === '1') {

View File

@ -10,6 +10,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./app/context-builder": {
"types": "./dist/app/context-builder.d.ts",
"default": "./dist/app/context-builder.js"
},
"./app/turn-runner": {
"types": "./dist/app/turn-runner.d.ts",
"default": "./dist/app/turn-runner.js"