aps-agent/server/app/service.ts

276 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 应用服务:会话与任务编排、能力查询、事件推送(架构设计第 2 节)。
*
* 它持有会话与业务记录,但不做业务规则、不替引擎判断。
* A1 阶段用内存实现;A3.7 换成持久层后接口保持不变。
*/
import { PROTOCOL_VERSION, type CapabilitiesResponse, type CancelResponse, type CreateSessionRequest, type CreateSessionResponse, type HealthResponse, type MeResponse, type PlatformEvent, type ProjectListResponse, type SendMessageRequest, type SendMessageResponse, type SessionDetailResponse, type SessionListResponse } from '@aps/contracts';
import { CapabilityRegistry } from '../core/registry/registry.js';
import { createCore, type Core, type InvokeRequest } from '../core/execution/invoke.js';
import { createFakeRuntime } from '../agent/fake-runtime.js';
import type { AgentRuntime, RuntimeSession } from '../agent/runtime.js';
import { createRulePlanner, plannerToFakeTurn } from '../agent/rule-planner.js';
import { toToolDescriptors } from '../agent/tools.js';
import type { SessionRepository } from '../core/storage/sessions.js';
import {
capabilityFingerprint,
visibleCapabilities,
type CapabilityVisibility,
} from '../core/registry/visibility.js';
import { runTurn } from './turn-runner.js';
import { PLATFORM_SYSTEM_PROMPT } from './system-prompt.js';
import type { Provider } from '@aps/provider-sdk';
import { AppError, badRequest, conflict } from './errors.js';
import { ProjectStore } from './projects.js';
import { newId, SessionStore, type SessionRecord } from './sessions.js';
import type { Subject } from './subject.js';
export interface ApplicationServiceOptions {
version?: string;
/** 引擎实现:默认是假引擎(A3.3 起可换成 Pi 子进程) */
runtime?: AgentRuntime;
/** 会话持久化(A3.7):不传则纯内存,测试与临时运行用 */
sessionRepository?: SessionRepository;
/** 能力可见性规则(A3.5):不传则开发期默认全部可见 */
capabilityVisibility?: CapabilityVisibility;
}
export class ApplicationService {
private readonly projects = new ProjectStore();
private readonly sessions: SessionStore;
private readonly version: string;
/** 能力目录与执行入口:能力清单来自这里,执行只有一个出口 */
readonly registry = new CapabilityRegistry();
readonly core: Core;
private readonly runtime: AgentRuntime;
private readonly visibility: CapabilityVisibility;
/** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */
private readonly runtimeSessions = new Map<string, RuntimeSession>();
/** 会话 → 工具集指纹:身份或可见能力变了就重建运行时会话 */
private readonly runtimeFingerprints = new Map<string, string>();
constructor(options: ApplicationServiceOptions = {}) {
this.version = options.version ?? '0.1.0';
this.sessions = new SessionStore(options.sessionRepository);
this.visibility = options.capabilityVisibility ?? { defaultAllow: true, denyWrite: false, grants: [] };
this.core = createCore({ registry: this.registry });
this.runtime = options.runtime ?? createFakeRuntime({ planner: plannerToFakeTurn(createRulePlanner()) });
}
/** 注册 Provider(A5 接入 APS、A8 接入第二个系统时调用) */
async registerProvider(provider: Provider): Promise<void> {
await this.registry.register(provider);
}
/** 执行记录查询:GET /v1/executions/{id} */
execution(executionId: string) {
return this.core.executions.get(executionId)?.receipt;
}
/** Core.invoke:工具调用的唯一出口(引擎侧经工具桥调用,见 A3.4) */
async invoke(request: InvokeRequest) {
return this.core.invoke(request);
}
health(): HealthResponse {
return { status: 'ok', version: this.version, protocolVersion: PROTOCOL_VERSION };
}
me(subject: Subject): MeResponse {
return {
userId: subject.userId,
tenantId: subject.tenantId,
displayName: subject.displayName,
clientKind: subject.clientKind,
};
}
listProjects(): ProjectListResponse {
return { projects: this.projects.list() };
}
listSessions(projectId?: string): SessionListResponse {
return { sessions: this.sessions.list(projectId) };
}
createSession(subject: Subject, request: CreateSessionRequest): CreateSessionResponse {
const scope = request.scope;
if (scope !== 'personal' && scope !== 'project') throw badRequest('scope 只能是 personal 或 project');
if (scope === 'project') {
const projectId = request.projectId;
if (!projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
if (!this.projects.get(projectId)) throw badRequest(`项目 ${projectId} 不存在`);
}
const record = this.sessions.create({ scope, projectId: request.projectId, title: request.title }, subject);
return { session: record.summary };
}
getSession(subject: Subject, sessionId: string): SessionDetailResponse {
const record = this.assertSessionAccess(subject, sessionId);
return { session: record.summary, messages: record.messages };
}
/** 这个身份能看到的能力:能力清单与引擎工具列表都走这里,保证两者一致 */
private visibleCapabilitiesFor(subject: Subject) {
return visibleCapabilities(this.registry.list(), this.visibility, { userId: subject.userId, tenantId: subject.tenantId });
}
/** 会话归属校验:不是自己的会话一律拒绝(协作归业务系统,见 D9) */
assertSessionAccess(subject: Subject, sessionId: string): SessionRecord {
const record = this.sessions.require(sessionId);
if (record.owner.userId !== subject.userId) {
throw new AppError('forbidden', '这是别人的会话', { status: 403 });
}
return record;
}
capabilities(subject: Subject, sessionId?: string): CapabilitiesResponse {
if (sessionId) this.assertSessionAccess(subject, sessionId);
return {
capabilities: this.visibleCapabilitiesFor(subject).map((capability) => {
const providerId = this.registry.resolve(capability.id)?.providerId ?? '';
return {
id: capability.id,
providerId,
title: capability.title ?? capability.description,
purpose: capability.description,
effect: capability.effect,
inputSchema: capability.inputSchema,
...(capability.synonyms === undefined ? {} : { synonyms: capability.synonyms }),
};
}),
};
}
sendMessage(subject: Subject, sessionId: string, request: SendMessageRequest): SendMessageResponse {
const record = this.assertSessionAccess(subject, sessionId);
if (typeof request.text !== 'string' || request.text.trim() === '') throw badRequest('text 不能为空');
if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length < 8) {
throw badRequest('idempotencyKey 至少 8 个字符');
}
// 幂等重试优先于「会话忙」判定:同一 key 的重试必须返回原结果,不能报冲突
const cached = this.sessions.findIdempotencyKey(sessionId, request.idempotencyKey);
if (cached) return { accepted: true, messageId: cached.messageId, runId: cached.runId };
if (record.state !== 'idle') throw conflict('上一个运行还没结束,先取消或等它完成');
const messageId = newId('m');
const runId = newId('r');
this.sessions.rememberIdempotencyKey(sessionId, request.idempotencyKey, messageId, runId);
this.sessions.appendMessage(sessionId, { messageId, role: 'user', ts: Date.now(), text: request.text });
record.bus.publish({ sessionId, runId, type: 'message.user', data: { messageId, text: request.text } });
const abort = new AbortController();
record.runs.set(runId, { runId, abort });
record.state = 'running';
void this.runTurn(record, runId, messageId, request.text, abort.signal);
return { accepted: true, messageId, runId };
}
/** 引擎回调入口(A3.11 工具桥的平台侧):按会话归属者发起调用,身份不由调用方自带 */
async invokeFromSession(
sessionId: string,
input: { capabilityId: string; params: unknown; idempotencyKey?: string; timeoutMs?: number },
) {
const record = this.sessions.require(sessionId);
return this.core.invoke({
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
sessionId,
projectId: record.summary.projectId,
capabilityId: input.capabilityId,
params: input.params,
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
});
}
cancel(subject: Subject, sessionId: string): CancelResponse {
const record = this.assertSessionAccess(subject, sessionId);
if (record.state !== 'running') return { state: 'idle' };
record.state = 'cancelling';
// 先让界面看到「正在取消」,再中止引擎与执行
const activeRun = [...record.runs.values()][0];
record.bus.publish({
sessionId,
...(activeRun === undefined ? {} : { runId: activeRun.runId }),
type: 'run.state',
data: { state: 'cancelling' },
});
this.runtimeSessions.get(sessionId)?.abort();
for (const run of record.runs.values()) run.abort.abort();
return { state: 'cancelling' };
}
/** 事件流保活:无事件时由入口层定时调用(协议文档第 5 节) */
heartbeat(sessionId: string): void {
this.sessions.require(sessionId).bus.publish({ sessionId, type: 'heartbeat', data: {} });
}
subscribe(subject: Subject, sessionId: string, since: number, listener: (event: PlatformEvent) => void): () => void {
return this.assertSessionAccess(subject, sessionId).bus.subscribe(since, listener);
}
lastSeq(sessionId: string): number {
return this.sessions.require(sessionId).bus.lastSeq;
}
private async runTurn(record: SessionRecord, runId: string, messageId: string, text: string, signal: AbortSignal): Promise<void> {
const sessionId = record.summary.sessionId;
const assistantMessageId = `${messageId}-reply`;
try {
const runtimeSession = await this.runtimeSessionFor(record);
const result = await runTurn({
runtimeSession,
sessionId,
runId,
assistantMessageId,
text,
signal,
publish: (event) => {
record.bus.publish(event);
},
invoke: (capabilityId, args, idempotencyKey) =>
this.invokeFromSession(sessionId, { capabilityId, params: args, idempotencyKey }),
});
if (result.text !== '') {
this.sessions.appendMessage(sessionId, { messageId: assistantMessageId, role: 'assistant', ts: Date.now(), text: result.text });
}
} catch (error) {
record.bus.publish({
sessionId,
runId,
type: 'error',
data: { code: 'engine.failed', message: error instanceof Error ? error.message : '引擎执行失败', retryable: true },
});
} finally {
record.runs.delete(runId);
if (record.state !== 'idle') record.state = 'idle';
}
}
/** 取(或创建)平台会话对应的运行时会话:工具来自能力目录,历史来自平台记录 */
private async runtimeSessionFor(record: SessionRecord): Promise<RuntimeSession> {
const sessionId = record.summary.sessionId;
const capabilities = this.visibleCapabilitiesFor(record.owner);
const fingerprint = `${record.owner.userId}|${capabilityFingerprint(capabilities)}`;
const existing = this.runtimeSessions.get(sessionId);
// 身份或工具集变了就重建:工具集不能跨身份复用(架构设计 5.1「按身份给工具」)
if (existing && this.runtimeFingerprints.get(sessionId) === fingerprint) return existing;
if (existing) {
await existing.dispose().catch(() => undefined);
this.runtimeSessions.delete(sessionId);
}
const created = await this.runtime.createSession({
sessionId,
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
projectId: record.summary.projectId,
system: PLATFORM_SYSTEM_PROMPT,
tools: toToolDescriptors(capabilities),
history: record.messages,
});
this.runtimeSessions.set(sessionId, created);
this.runtimeFingerprints.set(sessionId, fingerprint);
return created;
}
}