feat(agent): capability registry, Pi AgentRuntimePort, fake model provider and redaction guard (W1-5)
- CapabilityRegistry: descriptor(id/version/schema/scope/risk/idempotent/preview/replay), invoke = scope limit -> input schema -> server-side authorize -> idempotency key -> handler -> output schema -> audit; capability id <-> tool name mapping (download.submit <-> download__submit) - PiAgentRuntime (pi-agent-core AgentHarness 0.87.1): whitelist tools + before_tool re-check, redacted event stream, JSONL session at sessionDir/<runId>.jsonl, resume/abort/compact, tool-call/duration/token/cost budgets -> abort + run_failed(budget) - AgentSettings -> pi-ai createProvider (anthropic / openai-compatible), compaction mapping - FakeModelProvider + createFakeRuntime (scripted, transcript-derived, survives crash/resume) - buildSystemPrompt, redactForModel; 27 tests incl. SIGKILL + resume Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
1262340856
commit
9c881e737f
24 files changed
+2419
-8
No files matched your search
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
type AssistantMessage,
|
||||
createModels,
|
||||
type FauxContentBlock,
|
||||
type FauxResponseFactory,
|
||||
fauxAssistantMessage,
|
||||
fauxProvider,
|
||||
fauxText,
|
||||
fauxThinking,
|
||||
fauxToolCall,
|
||||
type JsonObject,
|
||||
type Message,
|
||||
type TranscriptContext,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { CapabilityRegistry } from "../capability/registry";
|
||||
import { capabilityIdToToolName, toolNameToCapabilityId } from "../capability/tool-name";
|
||||
import type { ResolvedPiModel } from "./model";
|
||||
import { PiAgentRuntime, type PiAgentRuntimeOptions } from "./runtime";
|
||||
|
||||
/** 前面各步工具调用的结果(按发生顺序),供脚本根据结果决定下一步 */
|
||||
export interface FakeToolResult {
|
||||
toolName: string;
|
||||
capabilityId?: string;
|
||||
isError: boolean;
|
||||
text: string;
|
||||
/** text 能解析成 JSON 时的值 */
|
||||
json?: unknown;
|
||||
}
|
||||
|
||||
export interface FakeToolCall {
|
||||
/** 能力 ID(自动映射为工具名) */
|
||||
capability?: string;
|
||||
/** 原始工具名(用于模拟模型调用白名单外的工具,如 bash) */
|
||||
toolName?: string;
|
||||
input: JsonObject | ((results: FakeToolResult[]) => JsonObject);
|
||||
}
|
||||
|
||||
export interface FakeStep {
|
||||
thinking?: string;
|
||||
text?: string;
|
||||
toolCalls?: FakeToolCall[];
|
||||
}
|
||||
|
||||
export interface FakeScript {
|
||||
name?: string;
|
||||
/** 匹配用户消息(通常就是 goal) */
|
||||
match: string | RegExp | ((goal: string) => boolean);
|
||||
steps: FakeStep[];
|
||||
finalText: string | ((results: FakeToolResult[]) => string);
|
||||
}
|
||||
|
||||
export interface FakeModelProviderOptions {
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
/** 压缩请求时返回的摘要文本 */
|
||||
summaryText?: string;
|
||||
}
|
||||
|
||||
const SUMMARY_MARKERS = ["context summarization assistant", "structured context checkpoint summary"];
|
||||
|
||||
/**
|
||||
* 脚本化假模型:无状态地从 transcript 推导当前步骤(最近一条用户消息之后已有几条 assistant 消息),
|
||||
* 因此进程崩溃后在新进程里 resume 也能从正确的步骤继续。不联网、不需要 API Key。
|
||||
*/
|
||||
export class FakeModelProvider {
|
||||
private readonly faux;
|
||||
private readonly scripts: FakeScript[];
|
||||
private readonly summaryText: string;
|
||||
/** 已收到的模型请求数(含压缩摘要请求) */
|
||||
calls = 0;
|
||||
/** 最近一次请求看到的系统提示与工具名(测试断言用) */
|
||||
lastRequest: { transcript: string } | undefined;
|
||||
|
||||
constructor(scripts: FakeScript[], options: FakeModelProviderOptions = {}) {
|
||||
this.scripts = scripts;
|
||||
this.summaryText = options.summaryText ?? "## 摘要\n目标:见首条消息。已完成动作:见工具结果。剩余事项:继续执行。";
|
||||
this.faux = fauxProvider({
|
||||
provider: "umt-fake",
|
||||
models: [
|
||||
{
|
||||
id: "fake-model",
|
||||
contextWindow: options.contextWindow ?? 64_000,
|
||||
maxTokens: options.maxTokens ?? 4096,
|
||||
reasoning: true,
|
||||
},
|
||||
],
|
||||
tokensPerSecond: 0,
|
||||
});
|
||||
const factory: FauxResponseFactory = (context) => {
|
||||
this.faux.appendResponses([factory]);
|
||||
return this.respond(context);
|
||||
};
|
||||
this.faux.setResponses([factory]);
|
||||
}
|
||||
|
||||
/** @internal 供 PiAgentRuntime 使用的模型装配 */
|
||||
resolve(): ResolvedPiModel {
|
||||
const models = createModels();
|
||||
models.setProvider(this.faux.provider);
|
||||
return { models, model: this.faux.getModel() };
|
||||
}
|
||||
|
||||
private respond(context: TranscriptContext): AssistantMessage {
|
||||
this.calls += 1;
|
||||
const messages = context.messages as Message[];
|
||||
const transcript = messages.map(messageText).join("\n");
|
||||
this.lastRequest = { transcript };
|
||||
const head = messages[0] ? messageText(messages[0]) : "";
|
||||
if (SUMMARY_MARKERS.some((m) => head.includes(m) || lastUserText(messages).includes(m))) {
|
||||
return fauxAssistantMessage(this.summaryText);
|
||||
}
|
||||
|
||||
const lastUser = lastUserIndex(messages);
|
||||
const goal = lastUser >= 0 ? messageText(messages[lastUser] as Message) : "";
|
||||
const script = this.scripts.find((s) => matches(s.match, goal));
|
||||
if (!script) {
|
||||
return fauxAssistantMessage(`[fake] 没有脚本匹配目标:${goal.slice(0, 80)}`, {
|
||||
stopReason: "error",
|
||||
errorMessage: "no fake script matched",
|
||||
});
|
||||
}
|
||||
const after = messages.slice(lastUser + 1);
|
||||
const stepIndex = after.filter((m) => m.role === "assistant").length;
|
||||
const results = toolResults(after);
|
||||
const step = script.steps[stepIndex];
|
||||
if (!step) {
|
||||
const text = typeof script.finalText === "function" ? script.finalText(results) : script.finalText;
|
||||
return fauxAssistantMessage(text);
|
||||
}
|
||||
const blocks: FauxContentBlock[] = [];
|
||||
if (step.thinking) blocks.push(fauxThinking(step.thinking));
|
||||
if (step.text) blocks.push(fauxText(step.text));
|
||||
for (const call of step.toolCalls ?? []) {
|
||||
const name = call.toolName ?? capabilityIdToToolName(call.capability ?? "");
|
||||
blocks.push(fauxToolCall(name, typeof call.input === "function" ? call.input(results) : call.input));
|
||||
}
|
||||
const hasTools = (step.toolCalls?.length ?? 0) > 0;
|
||||
return fauxAssistantMessage(blocks, hasTools ? { stopReason: "toolUse" } : {});
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateFakeRuntimeOptions extends Omit<PiAgentRuntimeOptions, "resolveModel"> {
|
||||
registry: CapabilityRegistry;
|
||||
scripts: FakeScript[];
|
||||
fake?: FakeModelProviderOptions;
|
||||
}
|
||||
|
||||
/** e2e/测试便捷函数:脚本化假模型 + 真实 PiAgentRuntime(注册中心、白名单、预算、JSONL 全部走真实路径) */
|
||||
export function createFakeRuntime(options: CreateFakeRuntimeOptions): {
|
||||
runtime: PiAgentRuntime;
|
||||
fake: FakeModelProvider;
|
||||
} {
|
||||
const { scripts, fake: fakeOptions, ...rest } = options;
|
||||
const fake = new FakeModelProvider(scripts, fakeOptions);
|
||||
return { runtime: new PiAgentRuntime({ ...rest, resolveModel: () => fake.resolve() }), fake };
|
||||
}
|
||||
|
||||
/** 供 settings 使用的假设置(provider 字段不会真正联网,模型由 FakeModelProvider 覆盖) */
|
||||
export const FAKE_AGENT_SETTINGS = {
|
||||
provider: "anthropic",
|
||||
apiKey: "fake-key-not-used",
|
||||
model: "fake-model",
|
||||
} as const;
|
||||
|
||||
function matches(match: FakeScript["match"], goal: string): boolean {
|
||||
if (typeof match === "string") return goal.includes(match);
|
||||
if (match instanceof RegExp) return match.test(goal);
|
||||
return match(goal);
|
||||
}
|
||||
|
||||
function messageText(m: Message): string {
|
||||
if (typeof m.content === "string") return m.content;
|
||||
return (m.content as Array<{ type: string; text?: string }>)
|
||||
.map((c) => (c.type === "text" ? (c.text ?? "") : ""))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function lastUserIndex(messages: Message[]): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) if (messages[i]?.role === "user") return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function lastUserText(messages: Message[]): string {
|
||||
const i = lastUserIndex(messages);
|
||||
return i >= 0 ? messageText(messages[i] as Message) : "";
|
||||
}
|
||||
|
||||
function toolResults(messages: Message[]): FakeToolResult[] {
|
||||
const out: FakeToolResult[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role !== "toolResult") continue;
|
||||
const text = messageText(m);
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = undefined;
|
||||
}
|
||||
const capabilityId = toolNameToCapabilityId(m.toolName);
|
||||
out.push({
|
||||
toolName: m.toolName,
|
||||
...(capabilityId ? { capabilityId } : {}),
|
||||
isError: m.isError,
|
||||
text,
|
||||
...(json !== undefined ? { json } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user