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:
parent
d5df4f5408
commit
e4b6051da1
|
|
@ -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'));
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue