diff --git a/scripts/context-builder.test.mjs b/scripts/context-builder.test.mjs new file mode 100644 index 0000000..5d2149c --- /dev/null +++ b/scripts/context-builder.test.mjs @@ -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')); +}); \ No newline at end of file diff --git a/scripts/context-injection.test.mjs b/scripts/context-injection.test.mjs new file mode 100644 index 0000000..b5d3015 --- /dev/null +++ b/scripts/context-injection.test.mjs @@ -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 }); + } +}); \ No newline at end of file diff --git a/server/agent/pi/runtime.ts b/server/agent/pi/runtime.ts index fd613ad..63e1f1f 100644 --- a/server/agent/pi/runtime.ts +++ b/server/agent/pi/runtime.ts @@ -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; diff --git a/server/app/context-builder.ts b/server/app/context-builder.ts new file mode 100644 index 0000000..f40a69a --- /dev/null +++ b/server/app/context-builder.ts @@ -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; +} + +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, + }; +} \ No newline at end of file diff --git a/server/app/service.ts b/server/app/service.ts index 4e6a879..eed3b95 100644 --- a/server/app/service.ts +++ b/server/app/service.ts @@ -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; } 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 | undefined; + /** 已经注入过上下文的运行时会话:同一运行时会话只注入一次(它自己有原生上下文) */ + private readonly injectedRuntimeSessions = new Set(); /** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */ private readonly runtimeSessions = new Map(); /** 会话 → 工具集指纹:身份或可见能力变了就重建运行时会话 */ @@ -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 { const sessionId = record.summary.sessionId; diff --git a/server/core/execution/store.ts b/server/core/execution/store.ts index 9a1a81d..580c506 100644 --- a/server/core/execution/store.ts +++ b/server/core/execution/store.ts @@ -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); } diff --git a/server/core/storage/context.ts b/server/core/storage/context.ts new file mode 100644 index 0000000..3d0a566 --- /dev/null +++ b/server/core/storage/context.ts @@ -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): 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, + }; + } +} \ No newline at end of file diff --git a/server/core/storage/db.ts b/server/core/storage/db.ts index 0d633af..1177896 100644 --- a/server/core/storage/db.ts +++ b/server/core/storage/db.ts @@ -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); diff --git a/server/index.ts b/server/index.ts index 85edc65..1dc32f6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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