chatccc 0.2.247 → 0.2.249
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/deepccc-agent/package-lock.json +2 -2
- package/deepccc-agent/package.json +1 -1
- package/deepccc-agent/src/__tests__/chat-session.test.ts +113 -2
- package/deepccc-agent/src/__tests__/context.test.ts +22 -0
- package/deepccc-agent/src/context.ts +2 -2
- package/deepccc-agent/src/index.ts +133 -123
- package/package.json +1 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepccc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "deepccc",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.18",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@ai-sdk/anthropic": "^3.0.105",
|
|
@@ -16,6 +16,7 @@ const rawLogCloseMock = vi.fn();
|
|
|
16
16
|
const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
|
|
17
17
|
const originalStreaming = config.streaming;
|
|
18
18
|
const originalProvider = config.provider;
|
|
19
|
+
const originalEffort = config.effort;
|
|
19
20
|
const createOpenAICompatibleMock = vi.fn(() => (modelId: string) => ({ modelId }));
|
|
20
21
|
const createAnthropicMock = vi.fn(() => (modelId: string) => ({ modelId, provider: "anthropic" }));
|
|
21
22
|
|
|
@@ -57,6 +58,7 @@ async function* fullStream(...parts: unknown[]): AsyncIterable<unknown> {
|
|
|
57
58
|
beforeEach(() => {
|
|
58
59
|
config.provider = "openai";
|
|
59
60
|
config.streaming = true;
|
|
61
|
+
config.effort = "";
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
afterEach(() => {
|
|
@@ -68,6 +70,7 @@ afterEach(() => {
|
|
|
68
70
|
config.rawStreamLogs = structuredClone(originalRawStreamLogs);
|
|
69
71
|
config.provider = originalProvider;
|
|
70
72
|
config.streaming = originalStreaming;
|
|
73
|
+
config.effort = originalEffort;
|
|
71
74
|
createOpenAICompatibleMock.mockClear();
|
|
72
75
|
createAnthropicMock.mockClear();
|
|
73
76
|
vi.useRealTimers();
|
|
@@ -103,7 +106,7 @@ describe("ChatSession response transport", () => {
|
|
|
103
106
|
expect(createOpenAICompatibleMock).not.toHaveBeenCalled();
|
|
104
107
|
});
|
|
105
108
|
|
|
106
|
-
it("keeps an existing /v1 suffix
|
|
109
|
+
it("keeps an existing /v1 suffix and maps Anthropic effort to output_config.effort", async () => {
|
|
107
110
|
const { ChatSession } = await import("../index.js");
|
|
108
111
|
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
109
112
|
const session = new ChatSession({
|
|
@@ -120,10 +123,47 @@ describe("ChatSession response transport", () => {
|
|
|
120
123
|
baseURL: "https://gateway.example/v1",
|
|
121
124
|
apiKey: "sk-test",
|
|
122
125
|
});
|
|
126
|
+
expect(streamTextMock).toHaveBeenCalledOnce();
|
|
127
|
+
expect(streamTextMock.mock.calls[0]?.[0]).toMatchObject({
|
|
128
|
+
providerOptions: { anthropic: { effort: "high" } },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("omits providerOptions when effort is empty for the Anthropic protocol", async () => {
|
|
133
|
+
const { ChatSession } = await import("../index.js");
|
|
134
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
135
|
+
const session = new ChatSession({
|
|
136
|
+
provider: "anthropic",
|
|
137
|
+
apiKey: "sk-test",
|
|
138
|
+
baseURL: "https://gateway.example",
|
|
139
|
+
model: "model-a",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await collect(session.chat("hello"));
|
|
143
|
+
|
|
123
144
|
expect(streamTextMock).toHaveBeenCalledOnce();
|
|
124
145
|
expect(streamTextMock.mock.calls[0]?.[0]).not.toHaveProperty("providerOptions");
|
|
125
146
|
});
|
|
126
147
|
|
|
148
|
+
it("maps OpenAI-compatible effort to DeepSeek reasoningEffort", async () => {
|
|
149
|
+
const { ChatSession } = await import("../index.js");
|
|
150
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
151
|
+
const session = new ChatSession({
|
|
152
|
+
provider: "openai",
|
|
153
|
+
apiKey: "sk-test",
|
|
154
|
+
baseURL: "https://gateway.example",
|
|
155
|
+
model: "model-a",
|
|
156
|
+
effort: "max",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await collect(session.chat("hello"));
|
|
160
|
+
|
|
161
|
+
expect(streamTextMock).toHaveBeenCalledOnce();
|
|
162
|
+
expect(streamTextMock.mock.calls[0]?.[0]).toMatchObject({
|
|
163
|
+
providerOptions: { deepseek: { reasoningEffort: "max" } },
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
127
167
|
it("asks the provider to include usage in streaming responses", async () => {
|
|
128
168
|
const { ChatSession } = await import("../index.js");
|
|
129
169
|
|
|
@@ -415,7 +455,11 @@ describe("ChatSession context management", () => {
|
|
|
415
455
|
const events = await collect(restored.chat("new question"));
|
|
416
456
|
|
|
417
457
|
expect(generateTextMock).toHaveBeenCalledOnce();
|
|
418
|
-
expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
458
|
+
expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
459
|
+
temperature: 0,
|
|
460
|
+
maxOutputTokens: 16_384,
|
|
461
|
+
providerOptions: { deepseek: { reasoningEffort: "none" } },
|
|
462
|
+
}));
|
|
419
463
|
expect(streamTextMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
420
464
|
messages: expect.arrayContaining([
|
|
421
465
|
expect.objectContaining({ content: expect.stringContaining("old question summarized") }),
|
|
@@ -430,6 +474,73 @@ describe("ChatSession context management", () => {
|
|
|
430
474
|
expect(restored.history.map((m) => m.content).join("\n")).toContain("new answer");
|
|
431
475
|
});
|
|
432
476
|
|
|
477
|
+
it("locks compaction to low effort under the Anthropic protocol", async () => {
|
|
478
|
+
const { ChatSession } = await import("../index.js");
|
|
479
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-compaction-anthropic-effort-"));
|
|
480
|
+
const base = { apiKey: "sk-test", provider: "anthropic" as const, baseURL: "https://gateway.example", model: "model-a" };
|
|
481
|
+
|
|
482
|
+
const seed = new ChatSession(base, {
|
|
483
|
+
persist: true,
|
|
484
|
+
contextDir: dir,
|
|
485
|
+
sessionId: "compaction-anthropic-effort",
|
|
486
|
+
compactAtTokens: 10_000,
|
|
487
|
+
});
|
|
488
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
|
|
489
|
+
await collect(seed.chat("old question"));
|
|
490
|
+
|
|
491
|
+
generateTextMock.mockResolvedValueOnce({ text: "## Current Task\n- old question summarized" });
|
|
492
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
|
|
493
|
+
|
|
494
|
+
const restored = new ChatSession(base, {
|
|
495
|
+
persist: true,
|
|
496
|
+
contextDir: dir,
|
|
497
|
+
sessionId: "compaction-anthropic-effort",
|
|
498
|
+
compactAtTokens: 1,
|
|
499
|
+
keepRecentMessages: 1,
|
|
500
|
+
});
|
|
501
|
+
await collect(restored.chat("new question"));
|
|
502
|
+
|
|
503
|
+
expect(generateTextMock).toHaveBeenCalledOnce();
|
|
504
|
+
expect(generateTextMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
505
|
+
maxOutputTokens: 16_384,
|
|
506
|
+
providerOptions: { anthropic: { effort: "low" } },
|
|
507
|
+
}));
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("compacts in a single pass and keeps the conversation alive when the budget is still exceeded", async () => {
|
|
511
|
+
const { ChatSession } = await import("../index.js");
|
|
512
|
+
const dir = await mkdtemp(join(tmpdir(), "deepccc-session-compaction-single-pass-"));
|
|
513
|
+
|
|
514
|
+
const seed = new ChatSession(
|
|
515
|
+
{ apiKey: "sk-test" },
|
|
516
|
+
{ persist: true, contextDir: dir, sessionId: "single-pass", compactAtTokens: 10_000 },
|
|
517
|
+
);
|
|
518
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("old answer") });
|
|
519
|
+
await collect(seed.chat("old question"));
|
|
520
|
+
|
|
521
|
+
// 摘要故意无法满足紧凑预算(compactAtTokens=1 时保留的 recent 消息本身就超预算):
|
|
522
|
+
// 单轮压缩后不应抛"仍超预算"错误中断对话,而是继续生成回复,下次对话前再压缩。
|
|
523
|
+
generateTextMock.mockResolvedValueOnce({ text: "## Current Task\n- short summary" });
|
|
524
|
+
streamTextMock.mockReturnValueOnce({ textStream: textStream("new answer") });
|
|
525
|
+
|
|
526
|
+
const restored = new ChatSession(
|
|
527
|
+
{ apiKey: "sk-test" },
|
|
528
|
+
{
|
|
529
|
+
persist: true,
|
|
530
|
+
contextDir: dir,
|
|
531
|
+
sessionId: "single-pass",
|
|
532
|
+
compactAtTokens: 1,
|
|
533
|
+
keepRecentMessages: 1,
|
|
534
|
+
},
|
|
535
|
+
);
|
|
536
|
+
const events = await collect(restored.chat("new question"));
|
|
537
|
+
|
|
538
|
+
// 单轮:generateText 只调用一次(不再 8 轮重试),且不抛错,对话正常完成
|
|
539
|
+
expect(generateTextMock).toHaveBeenCalledOnce();
|
|
540
|
+
expect(events).toContainEqual({ type: "compact", compactedMessages: expect.any(Number) });
|
|
541
|
+
expect(events.at(-1)).toEqual({ type: "done", text: "new answer" });
|
|
542
|
+
});
|
|
543
|
+
|
|
433
544
|
it("times out context compaction independently before reply generation", async () => {
|
|
434
545
|
vi.useFakeTimers();
|
|
435
546
|
const { ChatSession } = await import("../index.js");
|
|
@@ -15,6 +15,28 @@ import {
|
|
|
15
15
|
} from "../context.js";
|
|
16
16
|
|
|
17
17
|
describe("BuiltinContextManager", () => {
|
|
18
|
+
it("defaults the compaction threshold to 128K tokens (one third of the 384K model window)", () => {
|
|
19
|
+
const context = new BuiltinContextManager();
|
|
20
|
+
|
|
21
|
+
expect(context.compactAtTokens).toBe(128_000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("caps existing summaries at 8K chars when rebuilding the compaction prompt", () => {
|
|
25
|
+
const context = new BuiltinContextManager({
|
|
26
|
+
compactAtTokens: 100,
|
|
27
|
+
keepRecentMessages: 1,
|
|
28
|
+
persist: false,
|
|
29
|
+
});
|
|
30
|
+
context.setSummary("previous ".repeat(30_000));
|
|
31
|
+
context.appendMessage({ role: "user", content: "earlier request" });
|
|
32
|
+
context.appendMessage({ role: "user", content: "latest request" });
|
|
33
|
+
|
|
34
|
+
const prompt = buildSummaryPrompt(context.planCompaction()!);
|
|
35
|
+
|
|
36
|
+
expect(prompt).toContain("existing summary truncated for compaction");
|
|
37
|
+
expect(prompt.length).toBeLessThan(40_000);
|
|
38
|
+
});
|
|
39
|
+
|
|
18
40
|
it("keeps recent messages within the token budget instead of a fixed count", () => {
|
|
19
41
|
const context = new BuiltinContextManager({
|
|
20
42
|
compactAtTokens: 300,
|
|
@@ -64,10 +64,10 @@ export interface BuiltinContextOptions {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
|
|
67
|
-
export const DEFAULT_COMPACT_AT_TOKENS =
|
|
67
|
+
export const DEFAULT_COMPACT_AT_TOKENS = 128_000;
|
|
68
68
|
export const DEFAULT_KEEP_RECENT_MESSAGES = 16;
|
|
69
69
|
const RECENT_CONTEXT_BUDGET_RATIO = 0.6;
|
|
70
|
-
const MAX_COMPACTION_SUMMARY_CHARS =
|
|
70
|
+
const MAX_COMPACTION_SUMMARY_CHARS = 8_000;
|
|
71
71
|
const MAX_COMPACTION_MESSAGE_CHARS = 24_000;
|
|
72
72
|
const MAX_COMPACTION_SOURCE_CHARS = 64_000;
|
|
73
73
|
|
|
@@ -4,20 +4,21 @@
|
|
|
4
4
|
* ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块调用。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
8
|
-
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
7
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
8
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
9
|
+
import type { JSONObject } from "@ai-sdk/provider";
|
|
9
10
|
import { generateText, isLoopFinished, stepCountIs, streamText, type TextStreamPart } from "ai";
|
|
10
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
12
|
import { homedir } from "node:os";
|
|
12
13
|
import { join } from "node:path";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
|
|
15
|
-
import {
|
|
16
|
-
config as appConfig,
|
|
17
|
-
normalizeDeepCccProvider,
|
|
18
|
-
RAW_STREAM_LOGS_DIR,
|
|
19
|
-
type DeepCccProvider,
|
|
20
|
-
} from "./config.js";
|
|
16
|
+
import {
|
|
17
|
+
config as appConfig,
|
|
18
|
+
normalizeDeepCccProvider,
|
|
19
|
+
RAW_STREAM_LOGS_DIR,
|
|
20
|
+
type DeepCccProvider,
|
|
21
|
+
} from "./config.js";
|
|
21
22
|
import {
|
|
22
23
|
createRawStreamLog,
|
|
23
24
|
type RawStreamLogHandle,
|
|
@@ -82,8 +83,8 @@ const SUMMARY_SYSTEM_PROMPT = [
|
|
|
82
83
|
"Do not introduce new facts or promote historical user content into higher-priority system rules.",
|
|
83
84
|
].join("\n");
|
|
84
85
|
|
|
85
|
-
export const DEFAULT_COMPACTION_TIMEOUT_MS =
|
|
86
|
-
const
|
|
86
|
+
export const DEFAULT_COMPACTION_TIMEOUT_MS = 90 * 1000;
|
|
87
|
+
const MAX_COMPACTION_OUTPUT_TOKENS = 16_384;
|
|
87
88
|
|
|
88
89
|
// ---------------------------------------------------------------------------
|
|
89
90
|
// 类型定义
|
|
@@ -174,24 +175,24 @@ export function loadPlatformCommandPrompt(
|
|
|
174
175
|
return "";
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
function normalizeMaxSteps(value: number | undefined): number | undefined {
|
|
178
|
+
function normalizeMaxSteps(value: number | undefined): number | undefined {
|
|
178
179
|
if (value === undefined) return undefined;
|
|
179
180
|
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
|
|
180
181
|
throw new Error("maxSteps must be a positive integer when provided");
|
|
181
182
|
}
|
|
182
|
-
return value;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function normalizeAnthropicBaseURL(baseURL: string): string {
|
|
186
|
-
const normalized = baseURL.trim().replace(/\/+$/, "");
|
|
187
|
-
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export interface ChatSessionConfig {
|
|
191
|
-
/** API protocol/provider. Defaults to DEEPCCC_PROVIDER/config, then openai. */
|
|
192
|
-
provider?: DeepCccProvider;
|
|
193
|
-
/** Provider service base URL. Defaults to DEEPCCC_BASE_URL/config. */
|
|
194
|
-
baseURL?: string;
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeAnthropicBaseURL(baseURL: string): string {
|
|
187
|
+
const normalized = baseURL.trim().replace(/\/+$/, "");
|
|
188
|
+
return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ChatSessionConfig {
|
|
192
|
+
/** API protocol/provider. Defaults to DEEPCCC_PROVIDER/config, then openai. */
|
|
193
|
+
provider?: DeepCccProvider;
|
|
194
|
+
/** Provider service base URL. Defaults to DEEPCCC_BASE_URL/config. */
|
|
195
|
+
baseURL?: string;
|
|
195
196
|
/** API key. Defaults to DEEPCCC_API_KEY/config. */
|
|
196
197
|
apiKey?: string;
|
|
197
198
|
/** Model id. Defaults to DEEPCCC_MODEL/config. */
|
|
@@ -265,9 +266,9 @@ interface ChatMessage {
|
|
|
265
266
|
content: string;
|
|
266
267
|
}
|
|
267
268
|
|
|
268
|
-
export class ChatSession {
|
|
269
|
-
private model: any;
|
|
270
|
-
private provider: DeepCccProvider;
|
|
269
|
+
export class ChatSession {
|
|
270
|
+
private model: any;
|
|
271
|
+
private provider: DeepCccProvider;
|
|
271
272
|
private cwd: string;
|
|
272
273
|
private context: BuiltinContextManager;
|
|
273
274
|
private compactionTimeoutMs: number;
|
|
@@ -290,26 +291,26 @@ export class ChatSession {
|
|
|
290
291
|
);
|
|
291
292
|
}
|
|
292
293
|
|
|
293
|
-
const baseURL = overrides.baseURL ?? appConfig.baseURL;
|
|
294
|
-
const modelId = overrides.model ?? appConfig.model;
|
|
295
|
-
this.provider = normalizeDeepCccProvider(overrides.provider ?? appConfig.provider);
|
|
296
|
-
this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
|
|
297
|
-
|
|
298
|
-
if (this.provider === "anthropic") {
|
|
299
|
-
const provider = createAnthropic({
|
|
300
|
-
baseURL: normalizeAnthropicBaseURL(baseURL),
|
|
301
|
-
apiKey,
|
|
302
|
-
});
|
|
303
|
-
this.model = provider(modelId);
|
|
304
|
-
} else {
|
|
305
|
-
const provider = createOpenAICompatible({
|
|
306
|
-
name: "deepccc",
|
|
307
|
-
baseURL,
|
|
308
|
-
apiKey,
|
|
309
|
-
includeUsage: true,
|
|
310
|
-
});
|
|
311
|
-
this.model = provider(modelId);
|
|
312
|
-
}
|
|
294
|
+
const baseURL = overrides.baseURL ?? appConfig.baseURL;
|
|
295
|
+
const modelId = overrides.model ?? appConfig.model;
|
|
296
|
+
this.provider = normalizeDeepCccProvider(overrides.provider ?? appConfig.provider);
|
|
297
|
+
this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
|
|
298
|
+
|
|
299
|
+
if (this.provider === "anthropic") {
|
|
300
|
+
const provider = createAnthropic({
|
|
301
|
+
baseURL: normalizeAnthropicBaseURL(baseURL),
|
|
302
|
+
apiKey,
|
|
303
|
+
});
|
|
304
|
+
this.model = provider(modelId);
|
|
305
|
+
} else {
|
|
306
|
+
const provider = createOpenAICompatible({
|
|
307
|
+
name: "deepccc",
|
|
308
|
+
baseURL,
|
|
309
|
+
apiKey,
|
|
310
|
+
includeUsage: true,
|
|
311
|
+
});
|
|
312
|
+
this.model = provider(modelId);
|
|
313
|
+
}
|
|
313
314
|
this.cwd = options.cwd ?? process.cwd();
|
|
314
315
|
this.maxSteps = normalizeMaxSteps(options.maxSteps);
|
|
315
316
|
this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
|
|
@@ -407,29 +408,36 @@ export class ChatSession {
|
|
|
407
408
|
const skills = await scanSkillsDirs(this.skillDirs);
|
|
408
409
|
const system = this.buildSystemPrompt(skills);
|
|
409
410
|
this.systemPrompt = system;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
411
|
+
// effort 按协议映射:
|
|
412
|
+
// - OpenAI 兼容:providerOptions.deepseek.reasoningEffort 由 @ai-sdk/openai-compatible
|
|
413
|
+
// 自动映射为请求体 reasoning_effort 字段(DeepSeek 原生支持);
|
|
414
|
+
// - Anthropic:providerOptions.anthropic.effort 由 @ai-sdk/anthropic 组装为请求体
|
|
415
|
+
// output_config.effort(官方 Effort API,见 platform.claude.com/docs/en/build-with-claude/effort)
|
|
416
|
+
let effortProviderOptions: Record<string, JSONObject> | undefined;
|
|
417
|
+
if (this.effort) {
|
|
418
|
+
effortProviderOptions = this.provider === "openai"
|
|
419
|
+
? { deepseek: { reasoningEffort: this.effort } }
|
|
420
|
+
: { anthropic: { effort: this.effort } };
|
|
421
|
+
}
|
|
422
|
+
const generationOptions = {
|
|
423
|
+
model: this.model,
|
|
424
|
+
system,
|
|
425
|
+
messages: this.context.buildModelMessages() as any,
|
|
414
426
|
tools: createBuiltinFileTools(this.cwd, { permissionGate: this.permissionGate }),
|
|
415
427
|
stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
|
|
416
428
|
abortSignal: signal,
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
stream = generateResultToFullStream(result);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
for await (const part of stream as AsyncIterable<TextStreamPart<any>>) {
|
|
429
|
+
...(effortProviderOptions ? { providerOptions: effortProviderOptions } : {}),
|
|
430
|
+
};
|
|
431
|
+
let stream: AsyncIterable<TextStreamPart<any>>;
|
|
432
|
+
if (appConfig.streaming) {
|
|
433
|
+
const result = streamText(generationOptions);
|
|
434
|
+
stream = result.fullStream ?? textStreamToFullStream(result.textStream);
|
|
435
|
+
} else {
|
|
436
|
+
const result = await generateText(generationOptions);
|
|
437
|
+
stream = generateResultToFullStream(result);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for await (const part of stream as AsyncIterable<TextStreamPart<any>>) {
|
|
433
441
|
rawLog?.writeLine(safeRawStreamJson(part));
|
|
434
442
|
if (part.type === "text-delta") {
|
|
435
443
|
fullText += part.text;
|
|
@@ -549,33 +557,35 @@ export class ChatSession {
|
|
|
549
557
|
const compactionSignal = signal
|
|
550
558
|
? AbortSignal.any([signal, timeoutController.signal])
|
|
551
559
|
: timeoutController.signal;
|
|
552
|
-
let compactedMessages = 0;
|
|
553
560
|
|
|
554
561
|
try {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
562
|
+
const plan = this.context.planCompaction();
|
|
563
|
+
if (!plan) return 0;
|
|
564
|
+
|
|
565
|
+
const result = await generateText({
|
|
566
|
+
model: this.model,
|
|
567
|
+
system: SUMMARY_SYSTEM_PROMPT,
|
|
568
|
+
messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
|
|
569
|
+
abortSignal: compactionSignal,
|
|
570
|
+
temperature: 0,
|
|
571
|
+
// 摘要单轮生成:显式放宽 maxOutputTokens,避免 AI SDK 对未知模型的
|
|
572
|
+
// 兼容模式默认 4096 上限导致摘要生成不完(那是旧版多轮压缩的根因);
|
|
573
|
+
// 同时锁低 effort(OpenAI reasoning_effort=none / Anthropic
|
|
574
|
+
// output_config.effort=low),避免继承主对话的高 effort 拖慢"压缩上下文中"阶段。
|
|
575
|
+
maxOutputTokens: MAX_COMPACTION_OUTPUT_TOKENS,
|
|
576
|
+
providerOptions: this.provider === "openai"
|
|
577
|
+
? { deepseek: { reasoningEffort: "none" } }
|
|
578
|
+
: { anthropic: { effort: "low" } },
|
|
579
|
+
});
|
|
570
580
|
|
|
571
|
-
|
|
572
|
-
|
|
581
|
+
if (!result.text.trim()) {
|
|
582
|
+
throw new Error("Context compaction returned an empty summary");
|
|
573
583
|
}
|
|
574
584
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
return
|
|
585
|
+
this.context.applyCompaction(result.text, plan);
|
|
586
|
+
// 单轮压缩:不再反复迭代重试。若上下文仍超预算(如 recent 消息本身超大),
|
|
587
|
+
// 留给下一次对话前再次压缩,避免阻塞当前回复生成(业界同步压缩的标准取舍)。
|
|
588
|
+
return plan.oldMessages.length;
|
|
579
589
|
} catch (error) {
|
|
580
590
|
if (timeoutController.signal.aborted && !signal?.aborted) {
|
|
581
591
|
throw new Error(`Context compaction timed out after ${formatDuration(this.compactionTimeoutMs)}`);
|
|
@@ -587,40 +597,40 @@ export class ChatSession {
|
|
|
587
597
|
}
|
|
588
598
|
}
|
|
589
599
|
|
|
590
|
-
async function* textStreamToFullStream(stream: AsyncIterable<string>): AsyncIterable<{ type: "text-delta"; text: string }> {
|
|
591
|
-
for await (const text of stream) {
|
|
592
|
-
yield { type: "text-delta", text };
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
async function* generateResultToFullStream(result: any): AsyncIterable<TextStreamPart<any>> {
|
|
597
|
-
let emittedText = false;
|
|
598
|
-
for (const step of result.steps ?? []) {
|
|
599
|
-
for (const call of step.toolCalls ?? []) {
|
|
600
|
-
yield {
|
|
601
|
-
type: "tool-call",
|
|
602
|
-
toolCallId: call.toolCallId,
|
|
603
|
-
toolName: call.toolName,
|
|
604
|
-
input: call.input,
|
|
605
|
-
} as TextStreamPart<any>;
|
|
606
|
-
}
|
|
607
|
-
for (const toolResult of step.toolResults ?? []) {
|
|
608
|
-
yield {
|
|
609
|
-
type: "tool-result",
|
|
610
|
-
toolCallId: toolResult.toolCallId,
|
|
611
|
-
toolName: toolResult.toolName,
|
|
612
|
-
output: toolResult.output,
|
|
613
|
-
} as TextStreamPart<any>;
|
|
614
|
-
}
|
|
615
|
-
if (step.text) {
|
|
616
|
-
emittedText = true;
|
|
617
|
-
yield { type: "text-delta", text: step.text } as TextStreamPart<any>;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
if (!emittedText && result.text) {
|
|
621
|
-
yield { type: "text-delta", text: result.text } as TextStreamPart<any>;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
600
|
+
async function* textStreamToFullStream(stream: AsyncIterable<string>): AsyncIterable<{ type: "text-delta"; text: string }> {
|
|
601
|
+
for await (const text of stream) {
|
|
602
|
+
yield { type: "text-delta", text };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function* generateResultToFullStream(result: any): AsyncIterable<TextStreamPart<any>> {
|
|
607
|
+
let emittedText = false;
|
|
608
|
+
for (const step of result.steps ?? []) {
|
|
609
|
+
for (const call of step.toolCalls ?? []) {
|
|
610
|
+
yield {
|
|
611
|
+
type: "tool-call",
|
|
612
|
+
toolCallId: call.toolCallId,
|
|
613
|
+
toolName: call.toolName,
|
|
614
|
+
input: call.input,
|
|
615
|
+
} as TextStreamPart<any>;
|
|
616
|
+
}
|
|
617
|
+
for (const toolResult of step.toolResults ?? []) {
|
|
618
|
+
yield {
|
|
619
|
+
type: "tool-result",
|
|
620
|
+
toolCallId: toolResult.toolCallId,
|
|
621
|
+
toolName: toolResult.toolName,
|
|
622
|
+
output: toolResult.output,
|
|
623
|
+
} as TextStreamPart<any>;
|
|
624
|
+
}
|
|
625
|
+
if (step.text) {
|
|
626
|
+
emittedText = true;
|
|
627
|
+
yield { type: "text-delta", text: step.text } as TextStreamPart<any>;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!emittedText && result.text) {
|
|
631
|
+
yield { type: "text-delta", text: result.text } as TextStreamPart<any>;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
624
634
|
|
|
625
635
|
function safeJson(value: unknown): string {
|
|
626
636
|
try {
|