131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
|
|
/**
|
|||
|
|
* 假引擎(任务 A3.2):确定性的运行时,用于一致性测试与问法评测(A2.7)。
|
|||
|
|
*
|
|||
|
|
* 它不做任何「理解」:按脚本或计划器产出事件,形状与真引擎一致。
|
|||
|
|
* 这样一致性测试可以断言事件顺序,评测装置也能在无模型的情况下跑通。
|
|||
|
|
*/
|
|||
|
|
import { randomUUID } from 'node:crypto';
|
|||
|
|
|
|||
|
|
import type { ExecutionReceipt } from '@aps/contracts';
|
|||
|
|
import type { ErrorKind } from '@aps/provider-sdk';
|
|||
|
|
|
|||
|
|
import type { AgentRuntime, CreateRuntimeSessionInput, RuntimeEvent, RuntimeSession, SendOptions, ToolDescriptor } from './runtime.js';
|
|||
|
|
|
|||
|
|
/** 一个回合的脚本:先说话、再调工具、拿到回执后继续说话,或直接报错 */
|
|||
|
|
export interface FakeTurn {
|
|||
|
|
text?: string[];
|
|||
|
|
toolCalls?: Array<{ capabilityId: string; args: unknown }>;
|
|||
|
|
afterToolsText?: string[];
|
|||
|
|
error?: { kind: ErrorKind; message: string };
|
|||
|
|
/** 事件之间的间隔,默认 0(测试要快) */
|
|||
|
|
delayMs?: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface FakeRuntimeOptions {
|
|||
|
|
name?: string;
|
|||
|
|
/** 按回合顺序播放;用完后回到默认行为 */
|
|||
|
|
turns?: FakeTurn[];
|
|||
|
|
/** 或者由计划器决定每个回合怎么走 */
|
|||
|
|
planner?: (text: string, tools: ToolDescriptor[]) => FakeTurn;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface FakeRuntime extends AgentRuntime {
|
|||
|
|
stats: { sessions: number; turns: number; aborts: number };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sleep = (ms: number) => (ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve());
|
|||
|
|
|
|||
|
|
export function createFakeRuntime(options: FakeRuntimeOptions = {}): FakeRuntime {
|
|||
|
|
const stats = { sessions: 0, turns: 0, aborts: 0 };
|
|||
|
|
const scriptedTurns = [...(options.turns ?? [])];
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
name: options.name ?? 'fake',
|
|||
|
|
stats,
|
|||
|
|
async createSession(input: CreateRuntimeSessionInput): Promise<RuntimeSession> {
|
|||
|
|
stats.sessions += 1;
|
|||
|
|
const id = `fake-${input.sessionId}-${randomUUID().slice(0, 8)}`;
|
|||
|
|
const pending = new Map<string, (receipt: ExecutionReceipt) => void>();
|
|||
|
|
let aborted = false;
|
|||
|
|
let disposed = false;
|
|||
|
|
|
|||
|
|
const nextTurn = (text: string): FakeTurn => {
|
|||
|
|
const scripted = scriptedTurns.shift();
|
|||
|
|
if (scripted) return scripted;
|
|||
|
|
if (options.planner) return options.planner(text, input.tools);
|
|||
|
|
return { text: [`(假引擎)收到:${text}`] };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
async function* runTurn(text: string, sendOptions?: SendOptions): AsyncGenerator<RuntimeEvent> {
|
|||
|
|
if (disposed) throw new Error('运行时已释放');
|
|||
|
|
aborted = false;
|
|||
|
|
stats.turns += 1;
|
|||
|
|
const turn = nextTurn(text);
|
|||
|
|
const delay = turn.delayMs ?? 0;
|
|||
|
|
|
|||
|
|
const checkAbort = () => aborted || sendOptions?.signal?.aborted === true;
|
|||
|
|
if (checkAbort()) {
|
|||
|
|
yield { type: 'turn_end', reason: 'cancelled' };
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const delta of turn.text ?? []) {
|
|||
|
|
yield { type: 'text', delta };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (turn.error) {
|
|||
|
|
yield { type: 'error', kind: turn.error.kind, message: turn.error.message };
|
|||
|
|
yield { type: 'turn_end', reason: 'error' };
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const call of turn.toolCalls ?? []) {
|
|||
|
|
const callId = `call-${randomUUID().slice(0, 8)}`;
|
|||
|
|
// 先登记回调再 yield:消费者可能在收到事件的同一刻就把回执送回来
|
|||
|
|
const receiptPromise = new Promise<ExecutionReceipt>((resolve) => {
|
|||
|
|
pending.set(callId, resolve);
|
|||
|
|
});
|
|||
|
|
yield { type: 'tool_call', callId, capabilityId: call.capabilityId, args: call.args };
|
|||
|
|
|
|||
|
|
// 等平台把回执送回来(Pi 走扩展桥,这里走内存回调)
|
|||
|
|
const receipt = await receiptPromise;
|
|||
|
|
pending.delete(callId);
|
|||
|
|
yield { type: 'tool_result', callId, receipt };
|
|||
|
|
|
|||
|
|
if (checkAbort()) {
|
|||
|
|
yield { type: 'turn_end', reason: 'cancelled' };
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await sleep(delay);
|
|||
|
|
for (const delta of turn.afterToolsText ?? []) {
|
|||
|
|
yield { type: 'text', delta };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
yield { type: 'turn_end', reason: checkAbort() ? 'cancelled' : 'stop', usage: { turns: 1 } };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id,
|
|||
|
|
send(text: string, sendOptions?: SendOptions): AsyncIterable<RuntimeEvent> {
|
|||
|
|
return runTurn(text, sendOptions);
|
|||
|
|
},
|
|||
|
|
resolveToolResult(callId: string, receipt: ExecutionReceipt): void {
|
|||
|
|
const resolve = pending.get(callId);
|
|||
|
|
if (resolve) resolve(receipt);
|
|||
|
|
},
|
|||
|
|
abort(): void {
|
|||
|
|
stats.aborts += 1;
|
|||
|
|
aborted = true;
|
|||
|
|
for (const resolve of pending.values()) resolve({ executionId: 'aborted', status: 'failed', errorKind: 'conflict' });
|
|||
|
|
pending.clear();
|
|||
|
|
},
|
|||
|
|
async dispose(): Promise<void> {
|
|||
|
|
disposed = true;
|
|||
|
|
pending.clear();
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|