@akanjs/devkit 3.1.0-rc.11 → 3.1.0-rc.13
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/codeAgent/agent/AkanCodeCompaction.ts +49 -0
- package/codeAgent/agent/AkanCodeServices.ts +2 -3
- package/codeAgent/agent/CodeAgent.ts +46 -2
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -0
- package/codeAgent/agent/akanCodeCompaction.test.ts +36 -0
- package/codeAgent/agent/codeAgent.test.ts +61 -3
- package/codeAgent/agent/codeAgentContract.test.ts +1 -0
- package/codeAgent/index.ts +1 -0
- package/codeAgent/skills/akan-code/SKILL.md +4 -0
- package/codeAgent/skills/akan-store/SKILL.md +2 -1
- package/codeAgent/tools/AkanCodePlugins.ts +3 -1
- package/codeAgent/tools/DeepSeekWeb.ts +127 -0
- package/codeAgent/tools/WebToolPack.ts +124 -25
- package/codeAgent/tools/akanCodePlugins.test.ts +123 -0
- package/codeAgent/tools/deepSeekWeb.test.ts +113 -0
- package/package.json +2 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CodeAgentModel } from "./akanCodeModel";
|
|
3
|
+
|
|
4
|
+
type CompactedModel = Pick<CodeAgentModel, "provider" | "id" | "contextWindow">;
|
|
5
|
+
|
|
6
|
+
// The engine compacts above `contextWindow - reserveTokens`, so a share of the window becomes a reserve per model.
|
|
7
|
+
export class AkanCodeCompaction {
|
|
8
|
+
// Twice the engine's 16,384: at 16k a reasoning model stops mid-thought at the compaction threshold,
|
|
9
|
+
// and the recovery re-sends a near-full window to retry the turn.
|
|
10
|
+
static readonly reserveFloor = 32_768;
|
|
11
|
+
static readonly steps = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] as const;
|
|
12
|
+
|
|
13
|
+
static isStep(percent: number) {
|
|
14
|
+
return AkanCodeCompaction.steps.some((step) => step === percent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `100` is the default itself, so it is stored as no choice at all. */
|
|
18
|
+
static normalize(percent: number | undefined) {
|
|
19
|
+
if (percent === undefined) return undefined;
|
|
20
|
+
if (!AkanCodeCompaction.isStep(percent))
|
|
21
|
+
throw new Error(`Autocompact takes a percent in steps of 10: ${AkanCodeCompaction.steps.join(", ")}.`);
|
|
22
|
+
return percent === 100 ? undefined : percent;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static thresholdOf(contextWindow: number, percent: number | undefined) {
|
|
26
|
+
const share = percent ? Math.floor((contextWindow * percent) / 100) : contextWindow;
|
|
27
|
+
return Math.min(share, contextWindow - AkanCodeCompaction.reserveFloor);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static apply(settings: SettingsManager, model: CompactedModel, percent: number | undefined) {
|
|
31
|
+
const threshold = AkanCodeCompaction.thresholdOf(model.contextWindow, percent);
|
|
32
|
+
// Keyed by model, so the next model is never checked against a reserve sized for this one's window.
|
|
33
|
+
settings.applyOverrides({
|
|
34
|
+
compaction: {
|
|
35
|
+
modelOverrides: { [`${model.provider}/${model.id}`]: { reserveTokens: model.contextWindow - threshold } },
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why `percent` cannot work on `model`, or undefined when it can. */
|
|
41
|
+
static problemOf(model: CompactedModel, percent: number, keepRecentTokens: number) {
|
|
42
|
+
const name = `${model.provider}/${model.id}`;
|
|
43
|
+
if (model.contextWindow <= 0) return `${name} declares no context window, so it has no share to compact at.`;
|
|
44
|
+
const threshold = AkanCodeCompaction.thresholdOf(model.contextWindow, percent);
|
|
45
|
+
if (threshold >= keepRecentTokens) return undefined;
|
|
46
|
+
const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
|
|
47
|
+
return `Compacting at ${percent}% of the ${k(model.contextWindow)} window of ${name} starts at ${k(threshold)}, under the ${k(keepRecentTokens)} of recent turns every compaction keeps, so every turn would compact again.`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { CodeAgentProfile } from "akanjs/common";
|
|
6
6
|
import { AkanEnvKeys } from "../tools/AkanEnvKeys";
|
|
7
|
+
import { AkanCodeCompaction } from "./AkanCodeCompaction";
|
|
7
8
|
import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
|
|
8
9
|
import { akanCodePaths } from "./akanCodePaths";
|
|
9
10
|
import { akanSystemPrompt } from "./akanSystemPrompt";
|
|
@@ -27,9 +28,7 @@ export class AkanCodeServices {
|
|
|
27
28
|
enableInstallTelemetry: false,
|
|
28
29
|
enableAnalytics: false,
|
|
29
30
|
quietStartup: true,
|
|
30
|
-
|
|
31
|
-
// and the recovery re-sends a near-full window to retry the turn.
|
|
32
|
-
compaction: { reserveTokens: 32_768 },
|
|
31
|
+
compaction: { reserveTokens: AkanCodeCompaction.reserveFloor },
|
|
33
32
|
});
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
} from "akanjs/common";
|
|
26
26
|
import type { Workspace } from "../../commandDecorators";
|
|
27
27
|
import { AkanCodePlugins } from "../tools/AkanCodePlugins";
|
|
28
|
+
import { DeepSeekWeb } from "../tools/DeepSeekWeb";
|
|
29
|
+
import { AkanCodeCompaction } from "./AkanCodeCompaction";
|
|
28
30
|
import { AkanCodeServices } from "./AkanCodeServices";
|
|
29
31
|
import {
|
|
30
32
|
akanCodeModel,
|
|
@@ -52,6 +54,8 @@ export interface CodeAgentOptions {
|
|
|
52
54
|
cwd?: string;
|
|
53
55
|
profile: CodeAgentProfile;
|
|
54
56
|
model?: CodeAgentModelRef;
|
|
57
|
+
/** Percent of the context window that starts a compaction, in steps of 10. */
|
|
58
|
+
autoCompactAt?: number;
|
|
55
59
|
/** Apps whose dev server and preview the turn-end feedback loop watches. */
|
|
56
60
|
apps?: string[];
|
|
57
61
|
extensions?: InlineExtension[];
|
|
@@ -85,6 +89,7 @@ export class CodeAgent {
|
|
|
85
89
|
readonly #replay: CodeAgentEvent[] = [];
|
|
86
90
|
#replayFrom = 0;
|
|
87
91
|
#lastContextTokens: number | undefined;
|
|
92
|
+
#autoCompactAt: number | undefined;
|
|
88
93
|
|
|
89
94
|
private constructor(profile: CodeAgentProfile) {
|
|
90
95
|
this.#profile = profile;
|
|
@@ -95,8 +100,10 @@ export class CodeAgent {
|
|
|
95
100
|
const workspaceRoot = options.workspace.workspaceRoot;
|
|
96
101
|
const cwd = options.cwd ?? workspaceRoot;
|
|
97
102
|
const agent = new CodeAgent(options.profile);
|
|
103
|
+
const autoCompactAt = AkanCodeCompaction.normalize(options.autoCompactAt);
|
|
98
104
|
const settingsManager = AkanCodeServices.settings();
|
|
99
|
-
const
|
|
105
|
+
const proxy = CodeAgentProxy.fromEnv(options.profile);
|
|
106
|
+
const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, proxy);
|
|
100
107
|
const model = await akanCodeModel(modelRuntime, options.model);
|
|
101
108
|
const compaction = settingsManager.getCompactionSettings(model);
|
|
102
109
|
const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
|
|
@@ -107,6 +114,7 @@ export class CodeAgent {
|
|
|
107
114
|
profile: options.profile,
|
|
108
115
|
apps: options.apps ?? [],
|
|
109
116
|
canSeeImages: akanCodeModelSupportsImages(model),
|
|
117
|
+
deepseek: await DeepSeekWeb.fromRuntime(modelRuntime, !!proxy),
|
|
110
118
|
depth: options.depth ?? 0,
|
|
111
119
|
...(options.subagentSpend ? { subagentSpend: options.subagentSpend } : {}),
|
|
112
120
|
currentSessionId: () => agent.sessionId,
|
|
@@ -146,10 +154,12 @@ export class CodeAgent {
|
|
|
146
154
|
agent.#workspaceRoot = workspaceRoot;
|
|
147
155
|
if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
|
|
148
156
|
await agent.#attach(session, options.mode ?? "print");
|
|
157
|
+
const autoCompactWarning = agent.#fitAutoCompact(autoCompactAt);
|
|
149
158
|
agent.#suspended = new CodeAgentSuspended(CodeAgentSuspended.fileOf(agent.#sessionDir, agent.sessionId));
|
|
150
159
|
if (agent.#sessionDir && options.profile.session.store === "file")
|
|
151
160
|
agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
|
|
152
|
-
for (const message of modelWarnings
|
|
161
|
+
for (const message of [...modelWarnings, ...autoCompactWarning])
|
|
162
|
+
agent.#emit({ type: "notice", level: "warning", message });
|
|
153
163
|
return agent;
|
|
154
164
|
}
|
|
155
165
|
|
|
@@ -232,6 +242,7 @@ export class CodeAgent {
|
|
|
232
242
|
contextTokens: model?.contextWindow,
|
|
233
243
|
// Not `off`: that is a setting of a reasoning model, not the absence of reasoning.
|
|
234
244
|
effort: session.supportsThinking() ? session.thinkingLevel : undefined,
|
|
245
|
+
autoCompactAt: this.#autoCompactAt,
|
|
235
246
|
name: session.sessionName,
|
|
236
247
|
interaction: this.#profile.interaction,
|
|
237
248
|
};
|
|
@@ -396,9 +407,42 @@ export class CodeAgent {
|
|
|
396
407
|
const model = await akanCodeModel(session.modelRuntime, ref);
|
|
397
408
|
if (!model) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
|
|
398
409
|
await session.setModel(model);
|
|
410
|
+
for (const message of this.#fitAutoCompact(this.#autoCompactAt))
|
|
411
|
+
this.#emit({ type: "notice", level: "warning", message });
|
|
399
412
|
this.#emit({ type: "session", info: this.info });
|
|
400
413
|
}
|
|
401
414
|
|
|
415
|
+
setAutoCompactAt(percent: number | undefined) {
|
|
416
|
+
const wanted = AkanCodeCompaction.normalize(percent);
|
|
417
|
+
const problem = this.#autoCompactProblem(wanted);
|
|
418
|
+
if (problem) throw new Error(problem);
|
|
419
|
+
this.#autoCompactAt = wanted;
|
|
420
|
+
this.#applyAutoCompact();
|
|
421
|
+
this.#emit({ type: "session", info: this.info });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A share that fit one window can fall under the kept turns of a smaller one, and would compact every turn.
|
|
425
|
+
#fitAutoCompact(percent: number | undefined) {
|
|
426
|
+
const problem = this.#autoCompactProblem(percent);
|
|
427
|
+
this.#autoCompactAt = problem ? undefined : percent;
|
|
428
|
+
this.#applyAutoCompact();
|
|
429
|
+
return problem ? [`${problem} Autocompact is back to the default.`] : [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#autoCompactProblem(percent: number | undefined) {
|
|
433
|
+
const session = this.#require();
|
|
434
|
+
const model = session.model;
|
|
435
|
+
if (!model || !percent) return undefined;
|
|
436
|
+
const { keepRecentTokens } = session.settingsManager.getCompactionSettings(model);
|
|
437
|
+
return AkanCodeCompaction.problemOf(model, percent, keepRecentTokens);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Never before `create` has built the session: `settingsManager.reload()` drops whatever `applyOverrides` set.
|
|
441
|
+
#applyAutoCompact() {
|
|
442
|
+
const session = this.#require();
|
|
443
|
+
if (session.model) AkanCodeCompaction.apply(session.settingsManager, session.model, this.#autoCompactAt);
|
|
444
|
+
}
|
|
445
|
+
|
|
402
446
|
async waitForIdle() {
|
|
403
447
|
await this.#require().waitForIdle();
|
|
404
448
|
}
|
|
@@ -83,6 +83,9 @@ export class CodeAgentRpcHost {
|
|
|
83
83
|
case "set_model":
|
|
84
84
|
await this.#agent.setModel({ provider: command.provider, id: command.modelId });
|
|
85
85
|
return await this.#agent.state();
|
|
86
|
+
case "set_auto_compact":
|
|
87
|
+
this.#agent.setAutoCompactAt(command.percent);
|
|
88
|
+
return await this.#agent.state();
|
|
86
89
|
case "get_state":
|
|
87
90
|
return await this.#agent.state(command.sinceSeq === undefined ? {} : { sinceSeq: command.sinceSeq });
|
|
88
91
|
case "shutdown":
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { AkanCodeCompaction } from "./AkanCodeCompaction";
|
|
3
|
+
import { AkanCodeServices } from "./AkanCodeServices";
|
|
4
|
+
|
|
5
|
+
const big = { provider: "deepseek", id: "deepseek-flash", contextWindow: 1_000_000 };
|
|
6
|
+
const small = { provider: "openai", id: "small", contextWindow: 128_000 };
|
|
7
|
+
|
|
8
|
+
describe("AkanCodeCompaction", () => {
|
|
9
|
+
test("a percent is a share of the window, never closer to the edge than the reply's floor", () => {
|
|
10
|
+
expect(AkanCodeCompaction.thresholdOf(1_000_000, 60)).toBe(600_000);
|
|
11
|
+
expect(AkanCodeCompaction.thresholdOf(128_000, 90)).toBe(128_000 - AkanCodeCompaction.reserveFloor);
|
|
12
|
+
expect(AkanCodeCompaction.thresholdOf(1_000_000, undefined)).toBe(1_000_000 - AkanCodeCompaction.reserveFloor);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("only steps of 10 are taken, and 100 is the default rather than a choice", () => {
|
|
16
|
+
expect(AkanCodeCompaction.normalize(60)).toBe(60);
|
|
17
|
+
expect(AkanCodeCompaction.normalize(100)).toBeUndefined();
|
|
18
|
+
expect(AkanCodeCompaction.normalize(undefined)).toBeUndefined();
|
|
19
|
+
for (const wrong of [0, 55, 110, Number.NaN]) expect(() => AkanCodeCompaction.normalize(wrong)).toThrow();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the engine reads the share back as this model's reserve and leaves other models at the floor", () => {
|
|
23
|
+
const settings = AkanCodeServices.settings();
|
|
24
|
+
AkanCodeCompaction.apply(settings, big, 60);
|
|
25
|
+
expect(settings.getCompactionSettings(big).reserveTokens).toBe(400_000);
|
|
26
|
+
expect(settings.getCompactionSettings(small).reserveTokens).toBe(AkanCodeCompaction.reserveFloor);
|
|
27
|
+
AkanCodeCompaction.apply(settings, big, undefined);
|
|
28
|
+
expect(settings.getCompactionSettings(big).reserveTokens).toBe(AkanCodeCompaction.reserveFloor);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a share under the turns every compaction keeps is refused, since each turn would compact again", () => {
|
|
32
|
+
expect(AkanCodeCompaction.problemOf(big, 10, 20_000)).toBeUndefined();
|
|
33
|
+
expect(AkanCodeCompaction.problemOf(small, 10, 20_000)).toContain("every turn would compact again");
|
|
34
|
+
expect(AkanCodeCompaction.problemOf({ ...small, contextWindow: 0 }, 50, 20_000)).toContain("no context window");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -24,16 +24,31 @@ const chunk = (delta: object, finish: string | null = null) =>
|
|
|
24
24
|
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
25
25
|
})}\n\n`;
|
|
26
26
|
|
|
27
|
+
const usageChunk = (promptTokens: number) =>
|
|
28
|
+
`data: ${JSON.stringify({
|
|
29
|
+
id: "c1",
|
|
30
|
+
object: "chat.completion.chunk",
|
|
31
|
+
created: 0,
|
|
32
|
+
model: akanCodeDefaultModel.id,
|
|
33
|
+
choices: [],
|
|
34
|
+
usage: { prompt_tokens: promptTokens, completion_tokens: 1, total_tokens: promptTokens + 1 },
|
|
35
|
+
})}\n\n`;
|
|
36
|
+
|
|
27
37
|
let llm: ReturnType<typeof Bun.serve>;
|
|
28
38
|
let llmCalls = 0;
|
|
39
|
+
let reportedTokens = 0;
|
|
29
40
|
beforeAll(() => {
|
|
30
41
|
llm = Bun.serve({
|
|
31
42
|
port: 0,
|
|
32
43
|
fetch: () => {
|
|
33
44
|
llmCalls += 1;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
45
|
+
const usage = reportedTokens ? usageChunk(reportedTokens) : "";
|
|
46
|
+
return new Response(
|
|
47
|
+
`${chunk({ role: "assistant", content: "ok" })}${chunk({}, "stop")}${usage}data: [DONE]\n\n`,
|
|
48
|
+
{
|
|
49
|
+
headers: { "content-type": "text/event-stream" },
|
|
50
|
+
},
|
|
51
|
+
);
|
|
37
52
|
},
|
|
38
53
|
});
|
|
39
54
|
});
|
|
@@ -84,6 +99,49 @@ describe("CodeAgent", () => {
|
|
|
84
99
|
}
|
|
85
100
|
});
|
|
86
101
|
|
|
102
|
+
test("autocompact is carried from creation, announced on the session frame, and refuses a step it lacks", async () => {
|
|
103
|
+
const root = await makeTempRoot();
|
|
104
|
+
const agent = await createAgent(root, { autoCompactAt: 60 });
|
|
105
|
+
try {
|
|
106
|
+
const events: CodeAgentEvent[] = [];
|
|
107
|
+
agent.on((event) => events.push(event));
|
|
108
|
+
expect(agent.info.autoCompactAt).toBe(60);
|
|
109
|
+
expect(() => agent.setAutoCompactAt(55)).toThrow("steps of 10");
|
|
110
|
+
expect(agent.info.autoCompactAt).toBe(60);
|
|
111
|
+
agent.setAutoCompactAt(100);
|
|
112
|
+
expect(agent.info.autoCompactAt).toBeUndefined();
|
|
113
|
+
expect(events.filter((event) => event.type === "session")).toHaveLength(1);
|
|
114
|
+
} finally {
|
|
115
|
+
agent.dispose();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a chosen share reaches the engine: a reply reported past it compacts, and the default lets it pass", async () => {
|
|
120
|
+
const compactions = async (options: Partial<CodeAgentOptions>) => {
|
|
121
|
+
const agent = await createAgent(await makeTempRoot(), options);
|
|
122
|
+
const reasons: string[] = [];
|
|
123
|
+
agent.on((event) => {
|
|
124
|
+
if (event.type === "compaction" && event.phase === "start") reasons.push(event.reason);
|
|
125
|
+
});
|
|
126
|
+
try {
|
|
127
|
+
// The 20k most recent tokens are kept, so a summary needs an older turn behind the filler.
|
|
128
|
+
for (const text of ["hello", "filler ".repeat(20_000)]) {
|
|
129
|
+
await agent.prompt(text);
|
|
130
|
+
await agent.waitForIdle();
|
|
131
|
+
}
|
|
132
|
+
reportedTokens = 650_000;
|
|
133
|
+
await agent.prompt("next");
|
|
134
|
+
await agent.waitForIdle();
|
|
135
|
+
} finally {
|
|
136
|
+
reportedTokens = 0;
|
|
137
|
+
agent.dispose();
|
|
138
|
+
}
|
|
139
|
+
return reasons;
|
|
140
|
+
};
|
|
141
|
+
expect(await compactions({})).toEqual([]);
|
|
142
|
+
expect(await compactions({ autoCompactAt: 60 })).toEqual(["threshold"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
87
145
|
test("a sub-agent hands its plugins the token tally of the tree that opened it", async () => {
|
|
88
146
|
const root = await makeTempRoot();
|
|
89
147
|
const build = AkanCodePlugins.build;
|
package/codeAgent/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Importing this barrel loads the engine (~122MiB resident); a host needing cheap pieces imports the owning module.
|
|
2
2
|
// agent/consoleToStderr is deliberately absent: it takes over globalThis.console, so an RPC entry imports it first.
|
|
3
|
+
export * from "./agent/AkanCodeCompaction";
|
|
3
4
|
export * from "./agent/AkanCodeServices";
|
|
4
5
|
export * from "./agent/akanCodeModel";
|
|
5
6
|
export * from "./agent/akanCodePaths";
|
|
@@ -78,6 +78,9 @@ Stored under `.akan/code/sessions` — project history, so a second checkout of
|
|
|
78
78
|
- `/peers` lists other `akan code` sessions running in this workspace; `/msg <peer> <text>` hands one a
|
|
79
79
|
message.
|
|
80
80
|
- `/clear` wipes the screen and keeps the conversation; `/compact [notes]` shortens what the model carries.
|
|
81
|
+
- `/autocompact <percent>` compacts on its own once the window is that full, in steps of 10 (`/autocompact 60`);
|
|
82
|
+
`100` is the default, which waits until 32k remain for the reply. `akan code --compact-at 60` (`-c`) starts
|
|
83
|
+
with it, and a share under the 20k every compaction keeps is refused.
|
|
81
84
|
|
|
82
85
|
## Model, effort, keys
|
|
83
86
|
|
|
@@ -119,6 +122,7 @@ akan code -i "<prompt>" open the session with the prompt already sent
|
|
|
119
122
|
--app <name> narrow the agent to one app
|
|
120
123
|
--profile local|pod|review|web what it may do (default local)
|
|
121
124
|
--model <provider>/<id> --thinking, --json, --rpc, --resume <id>
|
|
125
|
+
--compact-at <percent> compact once the window is this full (10, 20, … 100)
|
|
122
126
|
```
|
|
123
127
|
|
|
124
128
|
`review` reads and never writes, loads no project context files, and keeps no session on disk. `pod` has
|
|
@@ -26,7 +26,8 @@ For a model `comment` (`<Slice>` is a signal slice suffix, e.g. `InPublic`):
|
|
|
26
26
|
|
|
27
27
|
## Generated state — `st.use.*`
|
|
28
28
|
|
|
29
|
-
`comment`, `commentLoading`, `commentForm`, `commentFormLoading`, `
|
|
29
|
+
`comment`, `commentLoading`, `commentForm`, `commentFormLoading`, `commentFormUploading` (uploads in flight, per
|
|
30
|
+
`File` field — non-empty means do not submit), `commentSubmit`, `commentModal`,
|
|
30
31
|
`commentViewAt`, `commentOperation`; per slice `commentList<Slice>`, `commentListLoading<Slice>`,
|
|
31
32
|
`commentInitList<Slice>`, `commentSelection<Slice>`, `commentInsight<Slice>`, `pageOfComment<Slice>`,
|
|
32
33
|
`lastPageOfComment<Slice>`, `hasMoreOfComment<Slice>`, `limitOfComment<Slice>`, `sortOfComment<Slice>`.
|
|
@@ -10,6 +10,7 @@ import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback"
|
|
|
10
10
|
import { VerifyFeedback } from "../feedback/VerifyFeedback";
|
|
11
11
|
import { AkanToolPack } from "./AkanToolPack";
|
|
12
12
|
import { AskToolPack } from "./AskToolPack";
|
|
13
|
+
import type { DeepSeekWeb } from "./DeepSeekWeb";
|
|
13
14
|
import { MailToolPack } from "./MailToolPack";
|
|
14
15
|
import { McpToolPack } from "./McpToolPack";
|
|
15
16
|
import { SessionToolPack } from "./SessionToolPack";
|
|
@@ -22,6 +23,7 @@ export interface AkanCodePluginsOptions {
|
|
|
22
23
|
/** Apps whose dev server should be watched, and whose page should be probed. */
|
|
23
24
|
apps: string[];
|
|
24
25
|
canSeeImages: boolean;
|
|
26
|
+
deepseek?: DeepSeekWeb | undefined;
|
|
25
27
|
/** How many `task` levels deep the owning agent is. */
|
|
26
28
|
depth: number;
|
|
27
29
|
/** The tally of the sub-agent tree the owning agent belongs to, when a `task` opened it. */
|
|
@@ -56,7 +58,7 @@ export class AkanCodePlugins {
|
|
|
56
58
|
});
|
|
57
59
|
const dispose = () => mcp?.close();
|
|
58
60
|
const mcpStatus = mcp?.status ?? [];
|
|
59
|
-
const web = new WebToolPack({ profile: options.profile });
|
|
61
|
+
const web = new WebToolPack({ profile: options.profile, deepseek: options.deepseek });
|
|
60
62
|
const sessions = new SessionToolPack({
|
|
61
63
|
workspaceRoot: options.workspace.workspaceRoot,
|
|
62
64
|
cwd: options.cwd,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { codeAgentClip } from "akanjs/common";
|
|
3
|
+
|
|
4
|
+
interface DeepSeekSearchResult {
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
snippet?: string;
|
|
8
|
+
summary?: string;
|
|
9
|
+
date?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DeepSeekPage {
|
|
13
|
+
title: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class DeepSeekWeb {
|
|
18
|
+
static readonly baseUrl = "https://api.deepseek.com/v1/tools";
|
|
19
|
+
static readonly maxQueryChars = 128;
|
|
20
|
+
static readonly summaryChars = 600;
|
|
21
|
+
// Only a successful call is billed, so a retry costs nothing. Search has no fallback and waits out every transient
|
|
22
|
+
// status; fetch waits only on its quota and otherwise falls back to a direct read, which beats DeepSeek's 504.
|
|
23
|
+
static readonly #retryable: { [tool in "search" | "fetch"]: number[] } = {
|
|
24
|
+
search: [408, 429, 500, 502, 503, 504],
|
|
25
|
+
fetch: [429],
|
|
26
|
+
};
|
|
27
|
+
static readonly #attempts = 3;
|
|
28
|
+
|
|
29
|
+
readonly #key: string;
|
|
30
|
+
readonly #baseUrl: string;
|
|
31
|
+
|
|
32
|
+
constructor(key: string, baseUrl = DeepSeekWeb.baseUrl) {
|
|
33
|
+
this.#key = key;
|
|
34
|
+
this.#baseUrl = baseUrl;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Undefined behind a proxy, where the runtime holds only a placeholder and the real key stays with the host. */
|
|
38
|
+
static async fromRuntime(runtime: ModelRuntime, proxied: boolean) {
|
|
39
|
+
if (proxied) return undefined;
|
|
40
|
+
const key = (await runtime.getAuth("deepseek").catch(() => undefined))?.auth.apiKey;
|
|
41
|
+
return key ? new DeepSeekWeb(key) : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async search(query: string, count: number, signal?: AbortSignal) {
|
|
45
|
+
if ([...query].length > DeepSeekWeb.maxQueryChars)
|
|
46
|
+
throw new Error(`DeepSeek search takes at most ${DeepSeekWeb.maxQueryChars} characters; shorten the query.`);
|
|
47
|
+
const body = await this.#call<{ results?: DeepSeekSearchResult[] }>("search", { query, count }, 20_000, signal);
|
|
48
|
+
const byUrl = new Map<string, DeepSeekSearchResult>();
|
|
49
|
+
for (const result of body.results ?? []) {
|
|
50
|
+
const url = DeepSeekWeb.#cleanUrl(result.url);
|
|
51
|
+
if (!byUrl.has(url)) byUrl.set(url, { ...result, url });
|
|
52
|
+
}
|
|
53
|
+
return [...byUrl.values()]
|
|
54
|
+
.map((result) => {
|
|
55
|
+
const date = result.date ? ` · ${result.date.slice(0, 10)}` : "";
|
|
56
|
+
const summary = DeepSeekWeb.unescape(result.summary || result.snippet || "")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
return `- ${result.title}\n ${result.url}${date}\n ${codeAgentClip(summary, DeepSeekWeb.summaryChars)}`;
|
|
60
|
+
})
|
|
61
|
+
.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async fetch(url: string, signal?: AbortSignal): Promise<DeepSeekPage> {
|
|
65
|
+
const page = await this.#call<{ title?: string; content?: string }>("fetch", { url }, 30_000, signal);
|
|
66
|
+
return { title: page.title ?? "", content: DeepSeekWeb.unescape(page.content ?? "") };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fetch escapes every ASCII punctuation mark, structure included (`\#\# Title`, `foo\(\)`), which costs tokens
|
|
70
|
+
// and garbles code. Undone only when nearly all punctuation is escaped, so a page already clean keeps its `\.`.
|
|
71
|
+
static unescape(markdown: string) {
|
|
72
|
+
const escaped = markdown.match(/\\[!-/:-@[-`{-~]/g)?.length ?? 0;
|
|
73
|
+
const punctuation = markdown.match(/[!-/:-@[-`{-~]/g)?.length ?? 0;
|
|
74
|
+
if (!escaped || escaped < (punctuation - escaped) / 2) return markdown;
|
|
75
|
+
return markdown.replace(/\\([!-/:-@[-`{-~])/g, "$1");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async #call<T>(tool: "search" | "fetch", body: object, timeoutMs: number, signal?: AbortSignal): Promise<T> {
|
|
79
|
+
for (let attempt = 1; ; attempt++) {
|
|
80
|
+
const response = await fetch(`${this.#baseUrl}/${tool}`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { authorization: `Bearer ${this.#key}`, "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
85
|
+
});
|
|
86
|
+
if (response.ok) return (await response.json()) as T;
|
|
87
|
+
if (!DeepSeekWeb.#retryable[tool].includes(response.status) || attempt >= DeepSeekWeb.#attempts) {
|
|
88
|
+
const error = (await response.json().catch(() => undefined)) as { error?: { message?: string } } | undefined;
|
|
89
|
+
throw new Error(
|
|
90
|
+
`DeepSeek ${tool} answered ${response.status}: ${error?.error?.message ?? response.statusText}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
await DeepSeekWeb.#wait(DeepSeekWeb.#delayOf(response, attempt), signal);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
static #delayOf(response: Response, attempt: number) {
|
|
98
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
99
|
+
return Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1000 : 1000 * 2 ** (attempt - 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static #wait(ms: number, signal?: AbortSignal) {
|
|
103
|
+
return new Promise<void>((resolve) => {
|
|
104
|
+
const timer = setTimeout(resolve, ms);
|
|
105
|
+
signal?.addEventListener(
|
|
106
|
+
"abort",
|
|
107
|
+
() => {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
resolve();
|
|
110
|
+
},
|
|
111
|
+
{ once: true },
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Drops the tracking parameters and the bare `#1` chunk marker DeepSeek appends to result URLs. */
|
|
117
|
+
static #cleanUrl(url: string) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
for (const name of [...parsed.searchParams.keys()]) if (name.startsWith("utm_")) parsed.searchParams.delete(name);
|
|
121
|
+
if (/^#\d+$/.test(parsed.hash)) parsed.hash = "";
|
|
122
|
+
return parsed.toString();
|
|
123
|
+
} catch {
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
1
2
|
import type { ExtensionAPI, InlineExtension } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { type CodeAgentProfile, codeAgentClip
|
|
3
|
+
import { type CodeAgentProfile, codeAgentClip } from "akanjs/common";
|
|
3
4
|
import { Type } from "typebox";
|
|
5
|
+
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
4
6
|
|
|
5
7
|
export interface WebToolPackOptions {
|
|
6
8
|
profile: CodeAgentProfile;
|
|
9
|
+
/** Serves search after any key-named provider, and reads public pages to Markdown ahead of a direct fetch. */
|
|
10
|
+
deepseek?: DeepSeekWeb | undefined;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
interface SearchProvider {
|
|
@@ -12,9 +16,27 @@ interface SearchProvider {
|
|
|
12
16
|
search(key: string, query: string, count: number): Promise<string>;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
interface WebSearcher {
|
|
20
|
+
name: string;
|
|
21
|
+
search(query: string, count: number, signal?: AbortSignal): Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface WebPage {
|
|
25
|
+
status: number;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
title: string;
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
// web_search only with a provider key set: a tool that can never work costs prompt tokens and invites retries.
|
|
16
32
|
export class WebToolPack {
|
|
33
|
+
static readonly pageChars = 10_000;
|
|
34
|
+
static readonly maxResults = 10;
|
|
35
|
+
static readonly #rememberedPages = 16;
|
|
17
36
|
readonly #options: WebToolPackOptions;
|
|
37
|
+
// A continuation reads the snapshot its first page came from; a fresh read always refetches, since a local dev
|
|
38
|
+
// page changes between edits.
|
|
39
|
+
readonly #pages = new Map<string, WebPage>();
|
|
18
40
|
|
|
19
41
|
constructor(options: WebToolPackOptions) {
|
|
20
42
|
this.#options = options;
|
|
@@ -52,7 +74,7 @@ export class WebToolPack {
|
|
|
52
74
|
names() {
|
|
53
75
|
const names: string[] = [];
|
|
54
76
|
if (this.#options.profile.tools.web.fetch) names.push("web_fetch");
|
|
55
|
-
if (this.#options.profile.tools.web.search &&
|
|
77
|
+
if (this.#options.profile.tools.web.search && this.#searcher()) names.push("web_search");
|
|
56
78
|
return names;
|
|
57
79
|
}
|
|
58
80
|
|
|
@@ -63,8 +85,8 @@ export class WebToolPack {
|
|
|
63
85
|
|
|
64
86
|
#register(pi: ExtensionAPI) {
|
|
65
87
|
if (this.#options.profile.tools.web.fetch) this.#registerFetch(pi);
|
|
66
|
-
const
|
|
67
|
-
if (this.#options.profile.tools.web.search &&
|
|
88
|
+
const searcher = this.#searcher();
|
|
89
|
+
if (this.#options.profile.tools.web.search && searcher) this.#registerSearch(pi, searcher);
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
#registerFetch(pi: ExtensionAPI) {
|
|
@@ -72,48 +94,125 @@ export class WebToolPack {
|
|
|
72
94
|
pi.registerTool({
|
|
73
95
|
name: "web_fetch",
|
|
74
96
|
label: "Fetch",
|
|
75
|
-
description:
|
|
97
|
+
description: `Fetch a URL and return its readable text, ${WebToolPack.pageChars} characters at a time. A longer page ends with the offset that continues it.`,
|
|
76
98
|
promptSnippet: "web_fetch: fetch a URL and return its readable text",
|
|
77
|
-
parameters: Type.Object({
|
|
78
|
-
|
|
99
|
+
parameters: Type.Object({
|
|
100
|
+
url: Type.String({ description: "Absolute http(s) URL." }),
|
|
101
|
+
offset: Type.Optional(Type.Number({ description: "Character offset a previous call named, to read on." })),
|
|
102
|
+
}),
|
|
103
|
+
execute: async (_id, params, signal) => {
|
|
79
104
|
const refusal = WebToolPack.#refuseHost(params.url, allowHosts);
|
|
80
105
|
if (refusal) return { content: [{ type: "text", text: refusal }], details: undefined, isError: true };
|
|
81
|
-
const
|
|
82
|
-
const
|
|
106
|
+
const offset = Math.max(0, Math.floor(params.offset ?? 0));
|
|
107
|
+
const cached = offset ? this.#pages.get(params.url) : undefined;
|
|
108
|
+
const page = cached ?? (await this.#read(params.url, signal));
|
|
109
|
+
if (page.ok) this.#remember(params.url, page);
|
|
83
110
|
return {
|
|
84
|
-
content: [
|
|
85
|
-
{ type: "text", text: `${response.status} ${params.url}\n\n${codeAgentClip(text, codeAgentOutputChars)}` },
|
|
86
|
-
],
|
|
111
|
+
content: [{ type: "text", text: WebToolPack.#window(params.url, page, offset) }],
|
|
87
112
|
details: undefined,
|
|
88
|
-
isError: !
|
|
113
|
+
isError: !page.ok,
|
|
89
114
|
};
|
|
90
115
|
},
|
|
91
116
|
});
|
|
92
117
|
}
|
|
93
118
|
|
|
94
|
-
#registerSearch(pi: ExtensionAPI,
|
|
119
|
+
#registerSearch(pi: ExtensionAPI, searcher: WebSearcher) {
|
|
120
|
+
const hint =
|
|
121
|
+
searcher.name === "deepseek"
|
|
122
|
+
? ` Keep the query within ${DeepSeekWeb.maxQueryChars} characters; site:, -site: and quoted phrases work.`
|
|
123
|
+
: "";
|
|
95
124
|
pi.registerTool({
|
|
96
125
|
name: "web_search",
|
|
97
126
|
label: "Search",
|
|
98
|
-
description: `Search the web (${
|
|
127
|
+
description: `Search the web (${searcher.name}) and return the top results as title, url and summary.${hint}`,
|
|
99
128
|
promptSnippet: "web_search: search the web and return titles, urls and snippets",
|
|
100
129
|
parameters: Type.Object({
|
|
101
130
|
query: Type.String(),
|
|
102
|
-
count: Type.Optional(
|
|
131
|
+
count: Type.Optional(
|
|
132
|
+
Type.Number({ description: `How many results, default 5, at most ${WebToolPack.maxResults}.` }),
|
|
133
|
+
),
|
|
103
134
|
}),
|
|
104
|
-
execute: async (_id, params) => {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
execute: async (_id, params, signal) => {
|
|
136
|
+
const count = Math.min(Math.max(Math.round(params.count ?? 5), 1), WebToolPack.maxResults);
|
|
137
|
+
try {
|
|
138
|
+
const results = await searcher.search(params.query, count, signal);
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: "text", text: codeAgentClip(results || "(no results)", WebToolPack.pageChars) }],
|
|
141
|
+
details: undefined,
|
|
142
|
+
};
|
|
143
|
+
} catch (error: unknown) {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
return { content: [{ type: "text", text: message }], details: undefined, isError: true };
|
|
146
|
+
}
|
|
111
147
|
},
|
|
112
148
|
});
|
|
113
149
|
}
|
|
114
150
|
|
|
115
|
-
|
|
116
|
-
|
|
151
|
+
#searcher(): WebSearcher | undefined {
|
|
152
|
+
const provider = WebToolPack.providers.find((entry) => !!process.env[entry.envKey]);
|
|
153
|
+
if (provider)
|
|
154
|
+
return {
|
|
155
|
+
name: provider.name,
|
|
156
|
+
search: (query, count) => provider.search(process.env[provider.envKey] ?? "", query, count),
|
|
157
|
+
};
|
|
158
|
+
const deepseek = this.#options.deepseek;
|
|
159
|
+
return deepseek
|
|
160
|
+
? { name: "deepseek", search: (query, count, signal) => deepseek.search(query, count, signal) }
|
|
161
|
+
: undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async #read(url: string, signal?: AbortSignal): Promise<WebPage> {
|
|
165
|
+
const deepseek = this.#options.deepseek;
|
|
166
|
+
if (deepseek && !WebToolPack.#isLocal(new URL(url).hostname)) {
|
|
167
|
+
try {
|
|
168
|
+
const page = await deepseek.fetch(url, signal);
|
|
169
|
+
return { status: 200, ok: true, title: page.title, text: page.content };
|
|
170
|
+
} catch (error: unknown) {
|
|
171
|
+
if (signal?.aborted) throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const response = await fetch(url, {
|
|
175
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000),
|
|
176
|
+
redirect: "follow",
|
|
177
|
+
});
|
|
178
|
+
return { status: response.status, ok: response.ok, title: "", text: WebToolPack.#readable(await response.text()) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#remember(url: string, page: WebPage) {
|
|
182
|
+
this.#pages.delete(url);
|
|
183
|
+
this.#pages.set(url, page);
|
|
184
|
+
const oldest = this.#pages.keys().next().value;
|
|
185
|
+
if (this.#pages.size > WebToolPack.#rememberedPages && oldest) this.#pages.delete(oldest);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
static #window(url: string, page: WebPage, offset: number) {
|
|
189
|
+
const head = [`${page.status} ${url}`, page.title].filter(Boolean).join("\n");
|
|
190
|
+
const total = page.text.length;
|
|
191
|
+
if (offset && offset >= total)
|
|
192
|
+
return `${head}\n\nOffset ${offset} is past the end; the page has ${total} characters.`;
|
|
193
|
+
const end = Math.min(offset + WebToolPack.pageChars, total);
|
|
194
|
+
const rest = end < total ? `\n\n[characters ${offset}–${end} of ${total}; offset ${end} reads on]` : "";
|
|
195
|
+
return `${head}\n\n${page.text.slice(offset, end)}${rest}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// DeepSeek's servers cannot reach these, and an intranet name has no business leaving the machine.
|
|
199
|
+
static #isLocal(hostname: string) {
|
|
200
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
201
|
+
const family = isIP(host);
|
|
202
|
+
if (family === 6) return host === "::1" || /^(f[cd]|fe[89ab])/.test(host);
|
|
203
|
+
if (family === 4) {
|
|
204
|
+
const [a = 0, b = 0] = host.split(".").map(Number);
|
|
205
|
+
return (
|
|
206
|
+
a === 0 ||
|
|
207
|
+
a === 10 ||
|
|
208
|
+
a === 127 ||
|
|
209
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
210
|
+
(a === 169 && b === 254) ||
|
|
211
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
212
|
+
(a === 192 && b === 168)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return !host.includes(".") || /\.(localhost|local|internal|lan|home\.arpa)$/.test(host);
|
|
117
216
|
}
|
|
118
217
|
|
|
119
218
|
static #refuseHost(url: string, allowHosts: string[] | undefined) {
|
|
@@ -11,6 +11,7 @@ import { AkanCodePlugins } from "./AkanCodePlugins";
|
|
|
11
11
|
import { AkanEditScope } from "./AkanEditScope";
|
|
12
12
|
import { AkanEnvKeys } from "./AkanEnvKeys";
|
|
13
13
|
import { AkanVerifier } from "./AkanVerifier";
|
|
14
|
+
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
14
15
|
import { McpClient } from "./McpClient";
|
|
15
16
|
import { McpToolPack } from "./McpToolPack";
|
|
16
17
|
import { SessionToolPack } from "./SessionToolPack";
|
|
@@ -652,6 +653,128 @@ describe("WebToolPack", () => {
|
|
|
652
653
|
server.stop(true);
|
|
653
654
|
}
|
|
654
655
|
});
|
|
656
|
+
|
|
657
|
+
test("reads a long page a window at a time, continuing from the snapshot it first read", async () => {
|
|
658
|
+
let hits = 0;
|
|
659
|
+
const server = Bun.serve({
|
|
660
|
+
port: 0,
|
|
661
|
+
fetch: () => {
|
|
662
|
+
hits += 1;
|
|
663
|
+
return new Response(`${"a".repeat(10_000)}${"b".repeat(10_000)}${"c".repeat(500)}`);
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
try {
|
|
667
|
+
const fetchTool = toolsOf(new WebToolPack({ profile: codeAgentPresets.local(root) }).extension()).get(
|
|
668
|
+
"web_fetch",
|
|
669
|
+
);
|
|
670
|
+
const url = `http://localhost:${server.port}/long`;
|
|
671
|
+
const read = async (offset?: number) =>
|
|
672
|
+
(await fetchTool?.("1", { url, ...(offset === undefined ? {} : { offset }) } as never))?.content[0]?.text ?? "";
|
|
673
|
+
|
|
674
|
+
const first = await read();
|
|
675
|
+
expect(first).toContain(`${"a".repeat(10_000)}\n\n[characters 0–10000 of 20500; offset 10000 reads on]`);
|
|
676
|
+
const second = await read(10_000);
|
|
677
|
+
expect(second).toContain(`\n\n${"b".repeat(10_000)}\n\n[characters 10000–20000 of 20500; offset 20000 reads on]`);
|
|
678
|
+
expect(await read(20_000)).toEndWith(`\n\n${"c".repeat(500)}`);
|
|
679
|
+
expect(await read(30_000)).toContain("Offset 30000 is past the end; the page has 20500 characters.");
|
|
680
|
+
expect(hits).toBe(1);
|
|
681
|
+
await read();
|
|
682
|
+
expect(hits).toBe(2);
|
|
683
|
+
} finally {
|
|
684
|
+
server.stop(true);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("reads a public page through DeepSeek, a local one directly, and a refused one directly too", async () => {
|
|
689
|
+
const asked: string[] = [];
|
|
690
|
+
const deepseekServer = Bun.serve({
|
|
691
|
+
port: 0,
|
|
692
|
+
fetch: async (request) => {
|
|
693
|
+
const { url } = (await request.json()) as { url: string };
|
|
694
|
+
asked.push(url);
|
|
695
|
+
if (url.endsWith("/refused"))
|
|
696
|
+
return Response.json({ error: { message: "rejected by security policy" } }, { status: 403 });
|
|
697
|
+
return Response.json({ title: "Install", content: "\\#\\# Install\n\nRun \\`bun add akanjs\\`\\." });
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
const page = Bun.serve({ port: 0, fetch: () => new Response("<h1>local dev page</h1>") });
|
|
701
|
+
const realFetch = globalThis.fetch;
|
|
702
|
+
const direct = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
703
|
+
input: RequestInfo | URL,
|
|
704
|
+
init?: RequestInit,
|
|
705
|
+
) => {
|
|
706
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
707
|
+
if (new URL(url).hostname === "docs.example.com") return new Response("<p>read directly</p>");
|
|
708
|
+
return await realFetch(input, init);
|
|
709
|
+
}) as typeof fetch);
|
|
710
|
+
try {
|
|
711
|
+
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
712
|
+
const fetchTool = toolsOf(new WebToolPack({ profile: codeAgentPresets.local(root), deepseek }).extension()).get(
|
|
713
|
+
"web_fetch",
|
|
714
|
+
);
|
|
715
|
+
const read = async (url: string) => (await fetchTool?.("1", { url } as never))?.content[0]?.text ?? "";
|
|
716
|
+
|
|
717
|
+
expect(await read("https://docs.example.com/install")).toBe(
|
|
718
|
+
"200 https://docs.example.com/install\nInstall\n\n## Install\n\nRun `bun add akanjs`.",
|
|
719
|
+
);
|
|
720
|
+
expect(await read(`http://localhost:${page.port}/`)).toContain("local dev page");
|
|
721
|
+
expect(asked).toEqual(["https://docs.example.com/install"]);
|
|
722
|
+
expect(await read("https://docs.example.com/refused")).toContain("read directly");
|
|
723
|
+
expect(asked.at(-1)).toBe("https://docs.example.com/refused");
|
|
724
|
+
} finally {
|
|
725
|
+
direct.mockRestore();
|
|
726
|
+
deepseekServer.stop(true);
|
|
727
|
+
page.stop(true);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
test("searches through DeepSeek when no key-named provider is set, and a key-named one wins", async () => {
|
|
732
|
+
const had = { brave: process.env.BRAVE_API_KEY, tavily: process.env.TAVILY_API_KEY };
|
|
733
|
+
const counts: number[] = [];
|
|
734
|
+
const deepseekServer = Bun.serve({
|
|
735
|
+
port: 0,
|
|
736
|
+
fetch: async (request) => {
|
|
737
|
+
counts.push(((await request.json()) as { count: number }).count);
|
|
738
|
+
return Response.json({
|
|
739
|
+
results: [{ title: "Akan.js", url: "https://akanjs.com/?utm_source=x", summary: "s" }],
|
|
740
|
+
});
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
const descriptionsOf = (pack: WebToolPack) => {
|
|
744
|
+
const descriptions = new Map<string, string>();
|
|
745
|
+
(pack.extension() as { factory: (pi: unknown) => void }).factory({
|
|
746
|
+
registerTool: (tool: { name: string; description: string }) => descriptions.set(tool.name, tool.description),
|
|
747
|
+
});
|
|
748
|
+
return descriptions;
|
|
749
|
+
};
|
|
750
|
+
try {
|
|
751
|
+
delete process.env.BRAVE_API_KEY;
|
|
752
|
+
delete process.env.TAVILY_API_KEY;
|
|
753
|
+
const deepseek = new DeepSeekWeb("sk-test", `http://127.0.0.1:${deepseekServer.port}/v1/tools`);
|
|
754
|
+
const pack = new WebToolPack({ profile: codeAgentPresets.local(root), deepseek });
|
|
755
|
+
expect(new WebToolPack({ profile: codeAgentPresets.review(root), deepseek }).names()).toEqual([]);
|
|
756
|
+
expect(descriptionsOf(pack).get("web_search")).toContain("(deepseek)");
|
|
757
|
+
|
|
758
|
+
const search = toolsOf(pack.extension()).get("web_search");
|
|
759
|
+
const found = await search?.("1", { query: "akan", count: 50 } as never);
|
|
760
|
+
expect(found?.content[0]?.text).toBe("- Akan.js\n https://akanjs.com/\n s");
|
|
761
|
+
expect(counts).toEqual([WebToolPack.maxResults]);
|
|
762
|
+
const tooLong = await search?.("1", { query: "x".repeat(DeepSeekWeb.maxQueryChars + 1) } as never);
|
|
763
|
+
expect(tooLong?.isError).toBe(true);
|
|
764
|
+
expect(counts).toHaveLength(1);
|
|
765
|
+
|
|
766
|
+
process.env.BRAVE_API_KEY = "test";
|
|
767
|
+
expect(descriptionsOf(pack).get("web_search")).toContain("(brave)");
|
|
768
|
+
} finally {
|
|
769
|
+
for (const [name, value] of [
|
|
770
|
+
["BRAVE_API_KEY", had.brave],
|
|
771
|
+
["TAVILY_API_KEY", had.tavily],
|
|
772
|
+
] as const)
|
|
773
|
+
if (value === undefined) delete process.env[name];
|
|
774
|
+
else process.env[name] = value;
|
|
775
|
+
deepseekServer.stop(true);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
655
778
|
});
|
|
656
779
|
|
|
657
780
|
describe("SessionToolPack", () => {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { DeepSeekWeb } from "./DeepSeekWeb";
|
|
3
|
+
|
|
4
|
+
interface Received {
|
|
5
|
+
path: string;
|
|
6
|
+
body: { query?: string; count?: number; url?: string };
|
|
7
|
+
auth: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const received: Received[] = [];
|
|
11
|
+
let answers: Response[] = [];
|
|
12
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
server = Bun.serve({
|
|
15
|
+
port: 0,
|
|
16
|
+
fetch: async (request) => {
|
|
17
|
+
received.push({
|
|
18
|
+
path: new URL(request.url).pathname,
|
|
19
|
+
body: (await request.json()) as Received["body"],
|
|
20
|
+
auth: request.headers.get("authorization"),
|
|
21
|
+
});
|
|
22
|
+
return answers.shift() ?? Response.json({ results: [] });
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
afterAll(() => server.stop(true));
|
|
27
|
+
|
|
28
|
+
const client = () => new DeepSeekWeb("sk-test", `http://127.0.0.1:${server.port}/v1/tools`);
|
|
29
|
+
const rateLimited = () =>
|
|
30
|
+
Response.json({ error: { message: "QPS exceeded" } }, { status: 429, headers: { "retry-after": "0.01" } });
|
|
31
|
+
|
|
32
|
+
describe("DeepSeekWeb", () => {
|
|
33
|
+
test("search lists each page once, without tracking parameters, with its date and a clipped summary", async () => {
|
|
34
|
+
answers = [
|
|
35
|
+
Response.json({
|
|
36
|
+
results: [
|
|
37
|
+
{
|
|
38
|
+
title: "Bun v1.3.13",
|
|
39
|
+
url: "https://bun.com/blog/bun-v1.3.13?utm_source=x&utm_medium=y&ref=keep",
|
|
40
|
+
summary: "word ".repeat(400),
|
|
41
|
+
date: "2026-04-19T16:00:00+00:00",
|
|
42
|
+
},
|
|
43
|
+
{ title: "Isolate issue", url: "https://github.com/oven-sh/bun/issues/31771#1", snippet: "only a snippet" },
|
|
44
|
+
{ title: "Same issue again", url: "https://github.com/oven-sh/bun/issues/31771", summary: "dup" },
|
|
45
|
+
],
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
48
|
+
const text = await client().search("bun isolate", 5);
|
|
49
|
+
expect(received.at(-1)).toEqual({
|
|
50
|
+
path: "/v1/tools/search",
|
|
51
|
+
body: { query: "bun isolate", count: 5 },
|
|
52
|
+
auth: "Bearer sk-test",
|
|
53
|
+
});
|
|
54
|
+
expect(text).toContain("- Bun v1.3.13\n https://bun.com/blog/bun-v1.3.13?ref=keep · 2026-04-19\n word word");
|
|
55
|
+
expect(text).toContain("- Isolate issue\n https://github.com/oven-sh/bun/issues/31771\n only a snippet");
|
|
56
|
+
expect(text).not.toContain("Same issue again");
|
|
57
|
+
expect(text.split("\n")[2]?.trim().length).toBe(DeepSeekWeb.summaryChars);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a query longer than DeepSeek takes is refused before any request is spent", async () => {
|
|
61
|
+
const before = received.length;
|
|
62
|
+
await expect(client().search("x".repeat(DeepSeekWeb.maxQueryChars + 1), 5)).rejects.toThrow("shorten the query");
|
|
63
|
+
expect(received.length).toBe(before);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("search waits out a rate limit, and gives up with DeepSeek's own message after the last attempt", async () => {
|
|
67
|
+
answers = [rateLimited(), Response.json({ results: [{ title: "t", url: "https://a.com/", summary: "s" }] })];
|
|
68
|
+
expect(await client().search("q", 1)).toContain("https://a.com/");
|
|
69
|
+
|
|
70
|
+
answers = [rateLimited(), rateLimited(), rateLimited()];
|
|
71
|
+
await expect(client().search("q", 1)).rejects.toThrow("429: QPS exceeded");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("fetch waits only on its quota and hands any other failure straight back", async () => {
|
|
75
|
+
answers = [rateLimited(), Response.json({ title: "Page", content: "body" })];
|
|
76
|
+
expect(await client().fetch("https://a.com/")).toEqual({ title: "Page", content: "body" });
|
|
77
|
+
|
|
78
|
+
const before = received.length;
|
|
79
|
+
answers = [Response.json({ error: { message: "downstream timeout" } }, { status: 504 })];
|
|
80
|
+
await expect(client().fetch("https://a.com/")).rejects.toThrow("504: downstream timeout");
|
|
81
|
+
expect(received.length).toBe(before + 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("fetch hands back Markdown with its blanket escaping undone", async () => {
|
|
85
|
+
answers = [
|
|
86
|
+
Response.json({
|
|
87
|
+
title: "Example",
|
|
88
|
+
content: "\\#\\# Usage\n\nCall \\`foo\\(1\\)\\` \\- it returns 2\\.\n\nconst re \\= \\/\\\\s\\+\\/\\;",
|
|
89
|
+
}),
|
|
90
|
+
];
|
|
91
|
+
const page = await client().fetch("https://example.com/usage");
|
|
92
|
+
expect(received.at(-1)?.body).toEqual({ url: "https://example.com/usage" });
|
|
93
|
+
expect(page).toEqual({
|
|
94
|
+
title: "Example",
|
|
95
|
+
content: "## Usage\n\nCall `foo(1)` - it returns 2.\n\nconst re = /\\s+/;",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a page that was never escaped keeps the backslashes it has", () => {
|
|
100
|
+
const clean = "Match a dot with `a\\.b`. See (the docs), [a link](x) and {a: 1}; done!";
|
|
101
|
+
expect(DeepSeekWeb.unescape(clean)).toBe(clean);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("the key comes from the model runtime, and never from behind a proxy", async () => {
|
|
105
|
+
const runtime = (apiKey?: string) =>
|
|
106
|
+
({ getAuth: async () => (apiKey ? { auth: { apiKey } } : undefined) }) as unknown as Parameters<
|
|
107
|
+
typeof DeepSeekWeb.fromRuntime
|
|
108
|
+
>[0];
|
|
109
|
+
expect(await DeepSeekWeb.fromRuntime(runtime("sk"), false)).toBeInstanceOf(DeepSeekWeb);
|
|
110
|
+
expect(await DeepSeekWeb.fromRuntime(runtime("sk"), true)).toBeUndefined();
|
|
111
|
+
expect(await DeepSeekWeb.fromRuntime(runtime(), false)).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.0-rc.
|
|
3
|
+
"version": "3.1.0-rc.13",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.0-rc.
|
|
51
|
+
"akanjs": "3.1.0-rc.13",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|