引入游玩回合与开场收口,抽出可替换 agent 司机并支持运行中止;完善预设洞位、世界书拼装与试跑,并打磨会话运行时与 Web 交互。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+62
-45
@@ -1,6 +1,10 @@
|
||||
import type { LlmConfig } from "../config/env.js";
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
import { consumeOpenAiToolStream } from "./stream-complete.js";
|
||||
import {
|
||||
currentAbortSignal,
|
||||
throwIfAborted,
|
||||
} from "./run-abort.js";
|
||||
|
||||
export type ToolCallPayload = {
|
||||
id: string;
|
||||
@@ -58,6 +62,8 @@ export type CompleteOptions = {
|
||||
generation?: GenerationParameters;
|
||||
/** 统计用途,如 main_agent / worker:write-rules */
|
||||
caller?: string;
|
||||
/** 取消进行中的 LLM 请求(停止并重试) */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type CompleteWithToolsOptions = CompleteOptions & {
|
||||
@@ -237,6 +243,24 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
|
||||
return { content: "", toolCalls };
|
||||
}
|
||||
|
||||
async function llmFetch(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
body: unknown,
|
||||
options?: CompleteOptions,
|
||||
): Promise<Response> {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
constructor(private readonly config: LlmConfig) {}
|
||||
|
||||
@@ -245,14 +269,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(buildRequestBody(this.config, messages, options)),
|
||||
});
|
||||
const response = await llmFetch(
|
||||
url,
|
||||
this.config.apiKey,
|
||||
buildRequestBody(this.config, messages, options),
|
||||
options,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
@@ -282,16 +304,12 @@ export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
callbacks: StreamCallbacks = {},
|
||||
): Promise<CompleteResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildRequestBody(this.config, messages, { ...options, stream: true }),
|
||||
),
|
||||
});
|
||||
const response = await llmFetch(
|
||||
url,
|
||||
this.config.apiKey,
|
||||
buildRequestBody(this.config, messages, { ...options, stream: true }),
|
||||
options,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
@@ -319,19 +337,15 @@ export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildRequestBody(this.config, messages, {
|
||||
...options,
|
||||
tools: options.tools,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const response = await llmFetch(
|
||||
url,
|
||||
this.config.apiKey,
|
||||
buildRequestBody(this.config, messages, {
|
||||
...options,
|
||||
tools: options.tools,
|
||||
}),
|
||||
options,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
@@ -362,20 +376,16 @@ export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
callbacks: StreamCallbacks,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(
|
||||
buildRequestBody(this.config, messages, {
|
||||
...options,
|
||||
tools: options.tools,
|
||||
stream: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const response = await llmFetch(
|
||||
url,
|
||||
this.config.apiKey,
|
||||
buildRequestBody(this.config, messages, {
|
||||
...options,
|
||||
tools: options.tools,
|
||||
stream: true,
|
||||
}),
|
||||
options,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
@@ -425,6 +435,7 @@ export class MockLlmProvider implements LlmProvider {
|
||||
_messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
throwIfAborted(options?.signal);
|
||||
const step = this.nextStep();
|
||||
const response =
|
||||
typeof step === "string"
|
||||
@@ -448,6 +459,7 @@ export class MockLlmProvider implements LlmProvider {
|
||||
options?: CompleteOptions,
|
||||
callbacks: StreamCallbacks = {},
|
||||
): Promise<CompleteResult> {
|
||||
throwIfAborted(options?.signal);
|
||||
const step = this.nextStep();
|
||||
const response =
|
||||
typeof step === "string"
|
||||
@@ -455,10 +467,12 @@ export class MockLlmProvider implements LlmProvider {
|
||||
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
|
||||
const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。";
|
||||
for (const ch of reasoning) {
|
||||
throwIfAborted(options?.signal);
|
||||
callbacks.onReasoningDelta?.(ch);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
for (const ch of response) {
|
||||
throwIfAborted(options?.signal);
|
||||
callbacks.onContentDelta?.(ch);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
@@ -480,6 +494,7 @@ export class MockLlmProvider implements LlmProvider {
|
||||
_messages: ChatMessage[],
|
||||
_options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
throwIfAborted(_options?.signal);
|
||||
const step = this.nextStep();
|
||||
if (typeof step === "string") {
|
||||
return {
|
||||
@@ -507,9 +522,11 @@ export class MockLlmProvider implements LlmProvider {
|
||||
_options: CompleteWithToolsOptions,
|
||||
callbacks: StreamCallbacks,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
throwIfAborted(_options?.signal);
|
||||
const reasoning =
|
||||
"用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。";
|
||||
for (const ch of reasoning) {
|
||||
throwIfAborted(_options?.signal);
|
||||
callbacks.onReasoningDelta?.(ch);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
+74
-34
@@ -1,4 +1,5 @@
|
||||
import { assemblePresetMessages, mergeMessages } from "../preset/assembler.js";
|
||||
import { loadAppSettings } from "../config/settings.js";
|
||||
import { resolveActivePreset } from "../preset/store.js";
|
||||
import type { PresetPackage } from "../types/preset.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
@@ -9,9 +10,35 @@ import type {
|
||||
LlmProvider,
|
||||
StreamCallbacks,
|
||||
} from "./client.js";
|
||||
import {
|
||||
TokenTrackingProvider,
|
||||
type LlmTrackingContext,
|
||||
} from "./token-tracker.js";
|
||||
|
||||
export type LlmTrackingRef = { current: LlmTrackingContext };
|
||||
|
||||
function readActivePreset(): PresetPackage | null {
|
||||
return resolveActivePreset(loadAppSettings().activePresetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在所有 LLM 请求前注入当前 preset 的 prompt 片段与生成参数。
|
||||
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
|
||||
* 当前没有选用预设时仍包一层,请求原样转发。
|
||||
*/
|
||||
export function wrapLlmForSession(
|
||||
inner: LlmProvider,
|
||||
trackingRef?: LlmTrackingRef,
|
||||
getPreset: () => PresetPackage | null = readActivePreset,
|
||||
): LlmProvider {
|
||||
const tracked = trackingRef
|
||||
? new TokenTrackingProvider(inner, () => trackingRef.current)
|
||||
: inner;
|
||||
return new PresetLlmProvider(tracked, getPreset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在所有 LLM 请求上合并当前 preset 的生成参数。
|
||||
* prompt / marker 不在此前置,由 play worker 按扩展 prompt_order 填洞。
|
||||
*/
|
||||
export class PresetLlmProvider implements LlmProvider {
|
||||
constructor(
|
||||
@@ -19,41 +46,57 @@ export class PresetLlmProvider implements LlmProvider {
|
||||
private readonly getPreset: () => PresetPackage | null,
|
||||
) {}
|
||||
|
||||
private prepare(
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): { messages: ChatMessage[]; options: CompleteOptions | undefined } {
|
||||
const preset = this.getPreset();
|
||||
if (!preset) return { messages, options };
|
||||
// 生成参数全局合并;prompt/marker 只在 play worker 按 prompt_order 填洞,这里不再前置。
|
||||
return {
|
||||
messages,
|
||||
options: {
|
||||
...options,
|
||||
generation: options?.generation ?? preset.generation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async complete(
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
): Promise<CompleteResult> {
|
||||
const preset = this.getPreset();
|
||||
if (!preset) {
|
||||
return this.inner.complete(messages, options);
|
||||
const prepared = this.prepare(messages, options);
|
||||
return this.inner.complete(prepared.messages, prepared.options);
|
||||
}
|
||||
|
||||
async completeStream(
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions,
|
||||
callbacks: StreamCallbacks = {},
|
||||
): Promise<CompleteResult> {
|
||||
if (!this.inner.completeStream) {
|
||||
const result = await this.complete(messages, options);
|
||||
if (result.reasoning) callbacks.onReasoningDelta?.(result.reasoning);
|
||||
if (result.content) callbacks.onContentDelta?.(result.content);
|
||||
return result;
|
||||
}
|
||||
|
||||
const presetMessages = assemblePresetMessages(preset);
|
||||
const merged = mergeMessages(presetMessages, messages);
|
||||
const generation = options?.generation ?? preset.generation;
|
||||
|
||||
return this.inner.complete(merged, {
|
||||
...options,
|
||||
generation,
|
||||
});
|
||||
const prepared = this.prepare(messages, options);
|
||||
return this.inner.completeStream(
|
||||
prepared.messages,
|
||||
prepared.options,
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
|
||||
async completeWithTools(
|
||||
messages: ChatMessage[],
|
||||
options: CompleteWithToolsOptions,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const preset = this.getPreset();
|
||||
if (!preset) {
|
||||
return this.inner.completeWithTools(messages, options);
|
||||
}
|
||||
|
||||
const presetMessages = assemblePresetMessages(preset);
|
||||
const merged = mergeMessages(presetMessages, messages);
|
||||
const generation = options.generation ?? preset.generation;
|
||||
|
||||
return this.inner.completeWithTools(merged, {
|
||||
const prepared = this.prepare(messages, options);
|
||||
return this.inner.completeWithTools(prepared.messages, {
|
||||
...options,
|
||||
generation,
|
||||
...prepared.options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,17 +105,14 @@ export class PresetLlmProvider implements LlmProvider {
|
||||
options: CompleteWithToolsOptions,
|
||||
callbacks: StreamCallbacks,
|
||||
): Promise<CompleteWithToolsResult> {
|
||||
const preset = this.getPreset();
|
||||
if (!this.inner.completeWithToolsStream) {
|
||||
return this.completeWithTools(messages, options);
|
||||
}
|
||||
const presetMessages = preset ? assemblePresetMessages(preset) : [];
|
||||
const merged = preset ? mergeMessages(presetMessages, messages) : messages;
|
||||
const generation = options.generation ?? preset?.generation;
|
||||
|
||||
return this.inner.completeWithToolsStream(merged, {
|
||||
...options,
|
||||
generation,
|
||||
}, callbacks);
|
||||
const prepared = this.prepare(messages, options);
|
||||
return this.inner.completeWithToolsStream(
|
||||
prepared.messages,
|
||||
{ ...options, ...prepared.options },
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
const storage = new AsyncLocalStorage<AbortSignal>();
|
||||
|
||||
export function runWithAbortSignal<T>(
|
||||
signal: AbortSignal,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return storage.run(signal, fn);
|
||||
}
|
||||
|
||||
export function currentAbortSignal(): AbortSignal | undefined {
|
||||
return storage.getStore();
|
||||
}
|
||||
|
||||
export function isAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
if (name === "AbortError") return true;
|
||||
const message = "message" in err ? String(err.message) : "";
|
||||
return /aborted|AbortError/i.test(message);
|
||||
}
|
||||
|
||||
export function throwIfAborted(signal?: AbortSignal): void {
|
||||
const active = signal ?? currentAbortSignal();
|
||||
if (!active?.aborted) return;
|
||||
const err = new Error("This operation was aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
@@ -32,14 +32,21 @@ export type LlmTrackingContext = {
|
||||
|
||||
function capturePendingTrace(
|
||||
ctx: LlmTrackingContext,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content?: string | null;
|
||||
tool_calls?: unknown;
|
||||
tool_call_id?: string;
|
||||
}>,
|
||||
caller: string | undefined,
|
||||
model?: string,
|
||||
generation?: CompleteOptions["generation"],
|
||||
): void {
|
||||
ctx.pendingContextTrace = buildContextTrace({
|
||||
caller: caller ?? "unknown",
|
||||
messages,
|
||||
model,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,7 +137,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
async () => {
|
||||
const result = await this.inner.complete(messages, options);
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||
capturePendingTrace(
|
||||
ctx,
|
||||
messages,
|
||||
options?.caller,
|
||||
result.model,
|
||||
options?.generation,
|
||||
);
|
||||
recordUsage(ctx, options?.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
@@ -168,7 +181,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
});
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options?.caller, result.model);
|
||||
capturePendingTrace(
|
||||
ctx,
|
||||
messages,
|
||||
options?.caller,
|
||||
result.model,
|
||||
options?.generation,
|
||||
);
|
||||
recordUsage(ctx, options?.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
@@ -189,7 +208,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
async () => {
|
||||
const result = await this.inner.completeWithTools(messages, options);
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||
capturePendingTrace(
|
||||
ctx,
|
||||
messages,
|
||||
options.caller,
|
||||
result.model,
|
||||
options.generation,
|
||||
);
|
||||
recordUsage(ctx, options.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
@@ -224,7 +249,13 @@ export class TokenTrackingProvider implements LlmProvider {
|
||||
onContentDelta: callbacks.onContentDelta,
|
||||
});
|
||||
const ctx = this.getContext();
|
||||
capturePendingTrace(ctx, messages, options.caller, result.model);
|
||||
capturePendingTrace(
|
||||
ctx,
|
||||
messages,
|
||||
options.caller,
|
||||
result.model,
|
||||
options.generation,
|
||||
);
|
||||
recordUsage(ctx, options.caller, result.model, result.usage);
|
||||
if (result.reasoning?.trim()) {
|
||||
ctx.pendingReasoning = result.reasoning.trim();
|
||||
|
||||
Reference in New Issue
Block a user