aps-agent/server/index.ts

181 lines
8.5 KiB
TypeScript
Raw Permalink 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 节)。
*
* 组装:应用服务 + HTTP / SSE 入口。控制面(core)与引擎适配(agent)在后续阶段接入。
*/
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { AgentRuntime } from './agent/runtime.js';
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 { RuntimeSupervisor } from './agent/process-manager.js';
import { IdentityContext } from './core/identity/context.js';
import { CredentialBroker } from './core/credentials/broker.js';
import { PendingActionStore } from './core/approval/store.js';
import { AuditLog } from './core/audit/log.js';
import { ApplicationService } from './app/service.js';
import type { Provider } from '@aps/provider-sdk';
import { createHttpServer } from './entry/http.js';
export { ApplicationService } from './app/service.js';
export type { Subject } from './app/subject.js';
export { createHttpServer } from './entry/http.js';
export interface StartOptions {
host?: string;
port?: number;
version?: string;
/** 引擎实现:默认假引擎;A3.3 起可换成 Pi 子进程,测试也可注入脚本化假引擎 */
runtime?: AgentRuntime;
/** 数据库路径:默认取配置;测试传 ':memory:' 以隔离 */
databasePath?: string;
/** 引擎覆盖:测试必须传 'fake',避免读到本机的真实模型配置 */
engine?: 'fake' | 'pi';
/** 能力可见性覆盖(A3.5):不传则用配置里的规则 */
capabilityVisibility?: CapabilityVisibility;
/** 确认卡有效期覆盖(A4.4):测试用,默认配置里的 15 分钟 */
approvalTtlMs?: number;
}
export interface RunningServer {
/** 本实例选定的引擎实现(A3.9 一致性对比、A3.10 进程管理要用) */
runtime: AgentRuntime;
url: string;
port: number;
service: ApplicationService;
/** 引擎工具回调的内部令牌:由平台生成,启动引擎子进程时注入(A3.11) */
internalToken: string;
close(): Promise<void>;
}
export async function startServer(options: StartOptions = {}): Promise<RunningServer> {
const config = loadConfig();
const host = options.host ?? config.server.host;
const port = options.port ?? config.server.port;
const internalToken = randomUUID();
// 端口要等 listen 之后才知道,所以桥信息用闭包延后取(A3.11 的能力桥需要它)
const bridgeRef: { info?: { baseUrl: string; internalToken: string } } = {};
const runtime = options.runtime ?? (await selectRuntime(config, options.engine, () => bridgeRef.info));
const database: Database = openDatabase(options.databasePath ?? config.database.path);
// 引擎进程管理(A3.10):空闲回收与并发上限可按环境变量调;日志走 stderr
const supervisor = new RuntimeSupervisor({
maxConcurrent: Number.parseInt(process.env.APS_MAX_CONCURRENT_ENGINES ?? '4', 10),
idleTimeoutMs: Number.parseInt(process.env.APS_ENGINE_IDLE_MS ?? String(15 * 60_000), 10),
onLog: (message) => console.error(message),
});
// A4 控制面五块:身份、凭据、待确认动作、审计落平台库;策略判定在 Core 内按同一批依赖执行
const service = new ApplicationService({
...(options.version === undefined ? {} : { version: options.version }),
runtime,
sessionRepository: new SessionRepository(database),
capabilityVisibility: options.capabilityVisibility ?? config.capabilities,
contextRepository: new ContextRepository(database),
supervisor,
identity: new IdentityContext(database),
credentials: new CredentialBroker({ db: database, keyFile: config.security.credentialKeyPath }),
approvals: new PendingActionStore(database),
audit: new AuditLog(database),
...(options.approvalTtlMs === undefined ? {} : { approvalTtlMs: options.approvalTtlMs }),
});
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
if (process.env.APS_DEV_PROVIDER === '1') {
const { createFakeProvider } = await import('@aps/provider-sdk/testing');
await service.registerProvider(createFakeProvider());
}
// 已配置的真实 Provider(A5):按配置动态加载,注册不上不阻塞平台启动
await registerConfiguredProviders(service, config.providers);
supervisor.startSweeper();
const server: Server = createHttpServer(service, internalToken);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => resolve());
});
const address = server.address() as AddressInfo | null;
const actualPort = address ? address.port : port;
// 监听完成后才拿得到真实端口,这时桥信息才可用
bridgeRef.info = { baseUrl: `http://${host}:${actualPort}`, internalToken };
return {
url: `http://${host}:${actualPort}`,
port: actualPort,
service,
runtime,
internalToken,
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
// 事件流是长连接,关停时必须主动断开,否则 close 会一直等它们
// 客户端断线后按 Last-Event-ID 重连,不会丢事件(协议文档第 5 节)
server.closeAllConnections();
});
// 等引擎子进程回收完成再返回:关停后不该有残留进程(A3.10)
await supervisor.disposeAll();
database.close();
},
};
}
/**
* 按配置加载 Provider(A5/A7 的可插拔位)。
*
* 用配置里的模块名做动态 import:平台代码不静态依赖任何具体 Provider(依赖方向检查不允许),
* 换一个 Provider 只改配置。注册失败只记账——APS 没起来时平台照样能启动,只是能力清单里没有它。
*/
async function registerConfiguredProviders(
service: ApplicationService,
providers: Array<{ module: string; baseUrl: string; token?: string; timeoutMs?: number }>,
): Promise<void> {
for (const instance of providers) {
try {
const loaded = (await import(instance.module)) as { createProvider?: (options: Record<string, unknown>) => Provider };
if (typeof loaded.createProvider !== 'function') {
console.error(`Provider ${instance.module} 没导出 createProvider(options),已跳过`);
continue;
}
const provider = loaded.createProvider({
baseUrl: instance.baseUrl,
...(instance.token === undefined ? {} : { token: instance.token }),
...(instance.timeoutMs === undefined ? {} : { timeoutMs: instance.timeoutMs }),
});
const manifest = await service.registerProvider(provider);
console.log(`Provider 已注册:${manifest.id}(${manifest.capabilities.length} 个能力)← ${instance.baseUrl}`);
} catch (error) {
console.error(`Provider ${instance.module} 注册失败(${instance.baseUrl}):${error instanceof Error ? error.message : '未知错误'}`);
}
}
}
/** 引擎选择:显式注入 > 配置里的 engine.kind;选 pi 但没密钥时明确回退到假引擎 */
async function selectRuntime(
config: ReturnType<typeof loadConfig>,
override?: 'fake' | 'pi',
bridge?: () => { baseUrl: string; internalToken: string } | undefined,
): Promise<AgentRuntime> {
const kind = override ?? config.engine.kind;
if (kind !== 'pi') {
const { createFakeRuntime } = await import('./agent/fake-runtime.js');
const { createRulePlanner, plannerToFakeTurn } = await import('./agent/rule-planner.js');
return createFakeRuntime({ name: 'fake', planner: plannerToFakeTurn(createRulePlanner()) });
}
if (config.llm.apiKey.trim() === '') {
console.error('警告:配置要求 Pi 引擎但没有模型密钥,已回退到假引擎(ADR-0011 允许未配置时降级)');
const { createFakeRuntime } = await import('./agent/fake-runtime.js');
const { createRulePlanner, plannerToFakeTurn } = await import('./agent/rule-planner.js');
return createFakeRuntime({ name: 'fake', planner: plannerToFakeTurn(createRulePlanner()) });
}
const { createPiRuntime } = await import('./agent/pi/runtime.js');
return createPiRuntime({
provider: config.llm.provider,
model: config.llm.model,
apiKey: config.llm.apiKey,
...(bridge === undefined ? {} : { bridge }),
});
}