@akanjs/devkit 3.0.0-beta.18 → 3.0.0-beta.19
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/abstractDoc.ts +4 -1
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +29 -3
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/capacitorApp.test.ts +2 -2
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +22 -3
- package/codeAgent/agent/CodeAgent.ts +33 -18
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/tools/mcpOAuth.test.ts +6 -2
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +8 -0
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/rolloutGate.test.ts +1 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AgentSession,
|
|
3
3
|
createAgentSession,
|
|
4
|
-
DEFAULT_COMPACTION_SETTINGS,
|
|
5
4
|
DefaultResourceLoader,
|
|
6
5
|
type ExtensionAPI,
|
|
7
6
|
type InlineExtension,
|
|
@@ -37,6 +36,8 @@ import { akanCodePaths } from "./akanCodePaths";
|
|
|
37
36
|
import { CodeAgentAsks } from "./CodeAgentAsks";
|
|
38
37
|
import { CodeAgentEventMapper } from "./CodeAgentEventMapper";
|
|
39
38
|
import { CodeAgentGate } from "./CodeAgentGate";
|
|
39
|
+
import { CodeAgentProxy } from "./CodeAgentProxy";
|
|
40
|
+
import { CodeAgentSuspended } from "./CodeAgentSuspended";
|
|
40
41
|
import { CodeAgentUi } from "./CodeAgentUi";
|
|
41
42
|
import { CodeMailbox } from "./CodeMailbox";
|
|
42
43
|
import { CodeSessionFork } from "./CodeSessionFork";
|
|
@@ -77,6 +78,7 @@ export class CodeAgent {
|
|
|
77
78
|
readonly #gate: CodeAgentGate;
|
|
78
79
|
readonly #mapper = new CodeAgentEventMapper();
|
|
79
80
|
readonly #asks = new CodeAgentAsks();
|
|
81
|
+
#suspended = new CodeAgentSuspended(null);
|
|
80
82
|
readonly #listeners = new Set<(event: CodeAgentEvent) => void>();
|
|
81
83
|
#session: AgentSession | undefined;
|
|
82
84
|
#disposePlugins: (() => void) | undefined;
|
|
@@ -106,9 +108,9 @@ export class CodeAgent {
|
|
|
106
108
|
const cwd = options.cwd ?? workspaceRoot;
|
|
107
109
|
const agent = new CodeAgent(options.profile);
|
|
108
110
|
const settingsManager = AkanCodeServices.settings();
|
|
109
|
-
const modelRuntime = await AkanCodeServices.runtime(workspaceRoot);
|
|
111
|
+
const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, CodeAgentProxy.fromEnv(options.profile));
|
|
110
112
|
const model = await akanCodeModel(modelRuntime, options.model);
|
|
111
|
-
const compaction =
|
|
113
|
+
const compaction = settingsManager.getCompactionSettings(model);
|
|
112
114
|
const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
|
|
113
115
|
|
|
114
116
|
const akan = await AkanCodePlugins.build({
|
|
@@ -153,9 +155,11 @@ export class CodeAgent {
|
|
|
153
155
|
agent.#disposePlugins = akan.dispose;
|
|
154
156
|
agent.#mcp = akan.mcp;
|
|
155
157
|
agent.#workspaceRoot = workspaceRoot;
|
|
156
|
-
if (options.profile.session.store
|
|
158
|
+
if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
|
|
157
159
|
await agent.#attach(session, options.mode ?? "print");
|
|
158
|
-
|
|
160
|
+
agent.#suspended = new CodeAgentSuspended(CodeAgentSuspended.fileOf(agent.#sessionDir, agent.sessionId));
|
|
161
|
+
if (agent.#sessionDir && options.profile.session.store === "file")
|
|
162
|
+
agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
|
|
159
163
|
for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
|
|
160
164
|
return agent;
|
|
161
165
|
}
|
|
@@ -262,6 +266,10 @@ export class CodeAgent {
|
|
|
262
266
|
announce() {
|
|
263
267
|
this.#emit({ type: "session", info: this.info });
|
|
264
268
|
this.#restore();
|
|
269
|
+
//* A resumed worker says again what it is still waiting on, or a host that reattached has no card to answer.
|
|
270
|
+
const { question, approvals } = this.#suspended;
|
|
271
|
+
if (question) this.#emit({ type: "question", question });
|
|
272
|
+
for (const request of approvals) this.#emit({ type: "approval", request });
|
|
265
273
|
}
|
|
266
274
|
|
|
267
275
|
/**
|
|
@@ -346,10 +354,13 @@ export class CodeAgent {
|
|
|
346
354
|
return true;
|
|
347
355
|
}
|
|
348
356
|
if (this.#profile.interaction.question !== "suspend") return false;
|
|
349
|
-
this.#
|
|
357
|
+
const suspended = this.#suspended.takeQuestion(questionId);
|
|
358
|
+
if (!suspended) return false;
|
|
359
|
+
const suspendedRendered = codeAgentRenderAnswer(suspended, answer);
|
|
360
|
+
this.#emit({ type: "question_resolved", questionId, answer, rendered: suspendedRendered });
|
|
350
361
|
// The answer has to reach the model as prose: compaction and the next turn read message content only, so
|
|
351
362
|
// an answer that exists solely as structure is one the agent will not remember being given.
|
|
352
|
-
await this.prompt(`The user answered: ${
|
|
363
|
+
await this.prompt(`The user answered: ${suspendedRendered}\nContinue the task.`);
|
|
353
364
|
return true;
|
|
354
365
|
}
|
|
355
366
|
|
|
@@ -359,6 +370,7 @@ export class CodeAgent {
|
|
|
359
370
|
return true;
|
|
360
371
|
}
|
|
361
372
|
if (this.#profile.interaction.approval !== "suspend") return false;
|
|
373
|
+
if (!this.#suspended.takeApproval(approvalId)) return false;
|
|
362
374
|
this.#emit({ type: "approval_resolved", approvalId, approved });
|
|
363
375
|
if (approved) await this.prompt("The user approved the pending action. Retry it and continue.");
|
|
364
376
|
return true;
|
|
@@ -561,17 +573,16 @@ export class CodeAgent {
|
|
|
561
573
|
// Nobody to ask means nobody to refuse: a pod would otherwise deny every write it was created to make.
|
|
562
574
|
if (!this.#profile.ui.canPrompt) return true;
|
|
563
575
|
const approvalId = this.#asks.nextId("a");
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
},
|
|
573
|
-
});
|
|
576
|
+
const request = {
|
|
577
|
+
approvalId,
|
|
578
|
+
toolCallId,
|
|
579
|
+
name,
|
|
580
|
+
summary: codeAgentClip(summary, codeAgentLabelChars),
|
|
581
|
+
policy: this.#profile.approval,
|
|
582
|
+
};
|
|
583
|
+
this.#emit({ type: "approval", request });
|
|
574
584
|
if (this.#profile.interaction.approval === "suspend") {
|
|
585
|
+
this.#suspended.addApproval(request);
|
|
575
586
|
this.#mapper.noteOutcome("awaiting");
|
|
576
587
|
return false;
|
|
577
588
|
}
|
|
@@ -586,10 +597,14 @@ export class CodeAgent {
|
|
|
586
597
|
* between them. An empty answer is a skip, which is a real answer and not a failure.
|
|
587
598
|
*/
|
|
588
599
|
async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
|
|
589
|
-
if (!this.#profile.ui.canPrompt) return undefined;
|
|
590
600
|
const question: CodeAgentQuestion = { questionId: this.#asks.nextId("q"), ...spec };
|
|
601
|
+
if (!this.#profile.ui.canPrompt) {
|
|
602
|
+
this.#emit({ type: "question_skipped", question, reason: "no-host" });
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
591
605
|
this.#emit({ type: "question", question });
|
|
592
606
|
if (this.#profile.interaction.question === "suspend") {
|
|
607
|
+
this.#suspended.setQuestion(question);
|
|
593
608
|
this.#mapper.noteOutcome("awaiting");
|
|
594
609
|
return undefined;
|
|
595
610
|
}
|
|
@@ -19,6 +19,8 @@ export class CodeAgentAsks {
|
|
|
19
19
|
#question: { question: CodeAgentQuestion; deferred: Deferred<string> } | undefined;
|
|
20
20
|
readonly #approvals = new Map<string, Deferred<boolean>>();
|
|
21
21
|
#nextId = 0;
|
|
22
|
+
//* Ids outlive the process on a suspending session, so a resumed worker must not hand out `q1` a second time.
|
|
23
|
+
readonly #epoch = Date.now().toString(36);
|
|
22
24
|
|
|
23
25
|
get pendingQuestionId() {
|
|
24
26
|
return this.#question?.question.questionId;
|
|
@@ -35,7 +37,7 @@ export class CodeAgentAsks {
|
|
|
35
37
|
|
|
36
38
|
nextId(prefix: string) {
|
|
37
39
|
this.#nextId += 1;
|
|
38
|
-
return `${prefix}${this.#nextId}`;
|
|
40
|
+
return `${prefix}${this.#epoch}.${this.#nextId}`;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
openQuestion(question: CodeAgentQuestion) {
|
|
@@ -79,7 +79,15 @@ export class CodeAgentEventMapper {
|
|
|
79
79
|
case "compaction_start":
|
|
80
80
|
return [{ type: "compaction", phase: "start", reason: event.reason }];
|
|
81
81
|
case "compaction_end":
|
|
82
|
-
return [
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
type: "compaction",
|
|
85
|
+
phase: "end",
|
|
86
|
+
reason: event.reason,
|
|
87
|
+
...(event.errorMessage ? { error: event.errorMessage } : {}),
|
|
88
|
+
...(event.aborted ? { aborted: true } : {}),
|
|
89
|
+
},
|
|
90
|
+
];
|
|
83
91
|
case "auto_retry_start":
|
|
84
92
|
return [
|
|
85
93
|
{
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { CodeAgentProfile } from "akanjs/common";
|
|
4
|
+
import { akanCodeDefaultModel } from "./akanCodeModel";
|
|
5
|
+
|
|
6
|
+
interface PreparedRequest {
|
|
7
|
+
model: { provider: string; baseUrl?: string };
|
|
8
|
+
options: { apiKey?: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type PrepareRequest = (model: unknown, options?: Record<string, unknown>) => Promise<PreparedRequest>;
|
|
12
|
+
|
|
13
|
+
export interface CodeAgentProxyOptions {
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
providers: string[];
|
|
16
|
+
tokenFile?: string;
|
|
17
|
+
token?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Routes every model request through a host's LLM proxy, so a pod never holds a provider key: the host mints a
|
|
22
|
+
* short-lived token, writes it to a file, and rotates it there while the worker keeps running.
|
|
23
|
+
*
|
|
24
|
+
* `baseUrl` may carry `{provider}` for a proxy that fronts several providers under one host.
|
|
25
|
+
*/
|
|
26
|
+
export class CodeAgentProxy {
|
|
27
|
+
static readonly placeholderKey = "akan-code-proxy";
|
|
28
|
+
|
|
29
|
+
readonly baseUrl: string;
|
|
30
|
+
readonly providers: string[];
|
|
31
|
+
readonly #tokenFile?: string;
|
|
32
|
+
readonly #token?: string;
|
|
33
|
+
|
|
34
|
+
constructor({ baseUrl, providers, tokenFile, token }: CodeAgentProxyOptions) {
|
|
35
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
36
|
+
this.providers = providers;
|
|
37
|
+
this.#tokenFile = tokenFile;
|
|
38
|
+
this.#token = token;
|
|
39
|
+
if (!tokenFile && !token)
|
|
40
|
+
throw new Error("AKAN_CODE_PROXY_URL needs AKAN_CODE_PROXY_TOKEN_FILE or AKAN_CODE_PROXY_TOKEN");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static fromEnv(profile?: CodeAgentProfile): CodeAgentProxy | null {
|
|
44
|
+
const baseUrl = process.env.AKAN_CODE_PROXY_URL?.trim() || profile?.network.proxyBaseUrl;
|
|
45
|
+
if (!baseUrl) return null;
|
|
46
|
+
const providers = (process.env.AKAN_CODE_PROXY_PROVIDERS ?? akanCodeDefaultModel.provider)
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((provider) => provider.trim())
|
|
49
|
+
.filter(Boolean);
|
|
50
|
+
return new CodeAgentProxy({
|
|
51
|
+
baseUrl,
|
|
52
|
+
providers,
|
|
53
|
+
tokenFile: process.env.AKAN_CODE_PROXY_TOKEN_FILE?.trim() || undefined,
|
|
54
|
+
token: process.env.AKAN_CODE_PROXY_TOKEN?.trim() || undefined,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
baseUrlFor(provider: string) {
|
|
59
|
+
return this.baseUrl.replaceAll("{provider}", provider);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//* Read per request, not cached: the host rotates a token that outlives no single turn, and a worker holding the
|
|
63
|
+
//* first one would start failing half an hour into a session it is still running.
|
|
64
|
+
async token() {
|
|
65
|
+
if (!this.#tokenFile) return this.#token ?? "";
|
|
66
|
+
const token = (await readFile(this.#tokenFile, "utf8")).trim();
|
|
67
|
+
if (!token) throw new Error(`${this.#tokenFile} is empty`);
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `prepareRequest` is the one path every stream, completion, compaction and cache-warm request takes, which is
|
|
73
|
+
* why it is wrapped rather than the public stream methods. It is private in the engine's typings, so its
|
|
74
|
+
* absence after an engine upgrade fails here at boot instead of sending a request with no key.
|
|
75
|
+
*/
|
|
76
|
+
async apply(runtime: ModelRuntime) {
|
|
77
|
+
const target = runtime as unknown as { prepareRequest?: PrepareRequest };
|
|
78
|
+
const original = target.prepareRequest?.bind(runtime);
|
|
79
|
+
if (!original) throw new Error("The model runtime no longer exposes prepareRequest; update CodeAgentProxy");
|
|
80
|
+
for (const provider of this.providers) await runtime.setRuntimeApiKey(provider, CodeAgentProxy.placeholderKey);
|
|
81
|
+
target.prepareRequest = async (model, options = {}) => {
|
|
82
|
+
const apiKey = await this.token();
|
|
83
|
+
const prepared = await original(model, { ...options, apiKey });
|
|
84
|
+
return {
|
|
85
|
+
...prepared,
|
|
86
|
+
model: { ...prepared.model, baseUrl: this.baseUrlFor(prepared.model.provider) },
|
|
87
|
+
options: { ...prepared.options, apiKey },
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { CodeAgentCommand, CodeAgentEvent, CodeAgentReply, CodeAgentRequest
|
|
|
2
2
|
import type { CodeAgent } from "./CodeAgent";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Serves one code agent
|
|
5
|
+
* Serves one code agent as newline-delimited akan wire frames, over stdio or whatever transport attaches.
|
|
6
6
|
*
|
|
7
7
|
* The protocol is **ours**, not the engine's: a host — the web relay, a pod runner, a test harness — reads the
|
|
8
8
|
* same events the in-process API emits, so an engine upgrade stops at the core instead of reaching every
|
|
@@ -13,23 +13,41 @@ import type { CodeAgent } from "./CodeAgent";
|
|
|
13
13
|
*/
|
|
14
14
|
export class CodeAgentRpcHost {
|
|
15
15
|
readonly #agent: CodeAgent;
|
|
16
|
-
|
|
16
|
+
#write: ((line: string) => void) | null;
|
|
17
17
|
#buffer = "";
|
|
18
|
+
#started = false;
|
|
19
|
+
#resolveShutdown: () => void = () => {};
|
|
20
|
+
readonly shutdown = new Promise<void>((resolve) => {
|
|
21
|
+
this.#resolveShutdown = resolve;
|
|
22
|
+
});
|
|
18
23
|
|
|
19
|
-
constructor(agent: CodeAgent, write: (line: string) => void = (line) => process.stdout.write(line)) {
|
|
24
|
+
constructor(agent: CodeAgent, write: ((line: string) => void) | null = (line) => process.stdout.write(line)) {
|
|
20
25
|
this.#agent = agent;
|
|
21
26
|
this.#write = write;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
async serve(input: AsyncIterable<Uint8Array> = process.stdin) {
|
|
25
|
-
this
|
|
26
|
-
this.#agent.announce();
|
|
30
|
+
this.start();
|
|
27
31
|
const decoder = new TextDecoder();
|
|
28
|
-
for await (const chunk of input) await this
|
|
32
|
+
for await (const chunk of input) await this.feed(decoder.decode(chunk, { stream: true }));
|
|
29
33
|
await this.#agent.abort().catch(() => {});
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
start() {
|
|
37
|
+
if (this.#started) return;
|
|
38
|
+
this.#started = true;
|
|
39
|
+
this.#agent.on((event) => this.#emit(event));
|
|
40
|
+
this.#agent.announce();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//* Frames emitted while no client is attached are dropped here and kept in the agent's replay buffer; a client
|
|
44
|
+
//* that attaches catches up with `get_state { sinceSeq }` instead of receiving a burst it did not ask for.
|
|
45
|
+
attach(write: ((line: string) => void) | null) {
|
|
46
|
+
this.#write = write;
|
|
47
|
+
this.#buffer = "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async feed(text: string) {
|
|
33
51
|
this.#buffer += text;
|
|
34
52
|
let index = this.#buffer.indexOf("\n");
|
|
35
53
|
while (index >= 0) {
|
|
@@ -52,6 +70,8 @@ export class CodeAgentRpcHost {
|
|
|
52
70
|
} catch (error) {
|
|
53
71
|
this.#reply({ type: "reply", id: request.id, ok: false, error: String(error) });
|
|
54
72
|
}
|
|
73
|
+
//* After the reply, so a socket transport that closes on shutdown has already written the acknowledgement.
|
|
74
|
+
if (request.command?.type === "shutdown") this.#resolveShutdown();
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
/**
|
|
@@ -87,10 +107,10 @@ export class CodeAgentRpcHost {
|
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
#emit(event: CodeAgentEvent) {
|
|
90
|
-
this.#write(`${JSON.stringify({ type: "event", event })}\n`);
|
|
110
|
+
this.#write?.(`${JSON.stringify({ type: "event", event })}\n`);
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
#reply(reply: CodeAgentReply) {
|
|
94
|
-
this.#write(`${JSON.stringify(reply)}\n`);
|
|
114
|
+
this.#write?.(`${JSON.stringify(reply)}\n`);
|
|
95
115
|
}
|
|
96
116
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import type { Socket, SocketListener } from "bun";
|
|
3
|
+
import type { CodeAgentRpcHost } from "./CodeAgentRpcHost";
|
|
4
|
+
|
|
5
|
+
export type CodeAgentRpcAddress = { unix: string } | { hostname: string; port: number };
|
|
6
|
+
|
|
7
|
+
interface ClientState {
|
|
8
|
+
pending: Buffer | null;
|
|
9
|
+
decoder: TextDecoder;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `akan code --rpc-listen <unix:PATH | tcp:[HOST:]PORT>` — the RPC host on a socket instead of stdio, so the agent
|
|
14
|
+
* outlives the process driving it. A control plane that restarts reconnects and resumes with
|
|
15
|
+
* `get_state { sinceSeq }`; the turn it was watching kept running in the meantime.
|
|
16
|
+
*
|
|
17
|
+
* One client at a time: a new connection replaces the old one, which is what a reconnecting control plane whose
|
|
18
|
+
* previous socket has not timed out yet needs. TCP binds 127.0.0.1 unless a host is named, because the wire has
|
|
19
|
+
* no authentication of its own — reach a pod through its unix socket and whatever the orchestrator already
|
|
20
|
+
* authenticates.
|
|
21
|
+
*/
|
|
22
|
+
export class CodeAgentRpcListener {
|
|
23
|
+
readonly #host: CodeAgentRpcHost;
|
|
24
|
+
readonly #address: CodeAgentRpcAddress;
|
|
25
|
+
#listener: SocketListener<ClientState> | null = null;
|
|
26
|
+
#client: Socket<ClientState> | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(host: CodeAgentRpcHost, address: CodeAgentRpcAddress) {
|
|
29
|
+
this.#host = host;
|
|
30
|
+
this.#address = address;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static parse(spec: string): CodeAgentRpcAddress {
|
|
34
|
+
if (spec.startsWith("unix:")) return { unix: spec.slice("unix:".length) };
|
|
35
|
+
const rest = spec.startsWith("tcp:") ? spec.slice("tcp:".length) : spec;
|
|
36
|
+
const separator = rest.lastIndexOf(":");
|
|
37
|
+
const hostname = separator >= 0 ? rest.slice(0, separator) : "127.0.0.1";
|
|
38
|
+
const port = Number(separator >= 0 ? rest.slice(separator + 1) : rest);
|
|
39
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
40
|
+
throw new Error(`--rpc-listen takes unix:<path> or tcp:[host:]<port>, not "${spec}"`);
|
|
41
|
+
return { hostname: hostname || "127.0.0.1", port };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get port() {
|
|
45
|
+
return this.#listener && "port" in this.#listener ? this.#listener.port : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
listen() {
|
|
49
|
+
if ("unix" in this.#address && existsSync(this.#address.unix)) rmSync(this.#address.unix);
|
|
50
|
+
const socket = {
|
|
51
|
+
open: (client: Socket<ClientState>) => this.#open(client),
|
|
52
|
+
data: (client: Socket<ClientState>, data: Buffer) => this.#data(client, data),
|
|
53
|
+
drain: (client: Socket<ClientState>) => this.#drain(client),
|
|
54
|
+
close: (client: Socket<ClientState>) => this.#close(client),
|
|
55
|
+
error: (client: Socket<ClientState>) => this.#close(client),
|
|
56
|
+
};
|
|
57
|
+
this.#listener =
|
|
58
|
+
"unix" in this.#address
|
|
59
|
+
? Bun.listen<ClientState>({ unix: this.#address.unix, socket })
|
|
60
|
+
: Bun.listen<ClientState>({ hostname: this.#address.hostname, port: this.#address.port, socket });
|
|
61
|
+
this.#host.start();
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async serve() {
|
|
66
|
+
if (!this.#listener) this.listen();
|
|
67
|
+
await this.#host.shutdown;
|
|
68
|
+
this.stop();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
stop() {
|
|
72
|
+
this.#client?.end();
|
|
73
|
+
this.#client = null;
|
|
74
|
+
this.#host.attach(null);
|
|
75
|
+
this.#listener?.stop(true);
|
|
76
|
+
this.#listener = null;
|
|
77
|
+
if ("unix" in this.#address && existsSync(this.#address.unix)) rmSync(this.#address.unix);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#open(client: Socket<ClientState>) {
|
|
81
|
+
client.data = { pending: null, decoder: new TextDecoder() };
|
|
82
|
+
const previous = this.#client;
|
|
83
|
+
this.#client = client;
|
|
84
|
+
previous?.end();
|
|
85
|
+
this.#host.attach((line) => this.#write(client, line));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#data(client: Socket<ClientState>, data: Buffer) {
|
|
89
|
+
if (client !== this.#client) return;
|
|
90
|
+
void this.#host.feed(client.data.decoder.decode(data, { stream: true }));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//* A socket write may take only part of a frame; the rest waits for `drain`, and frames behind it queue in order.
|
|
94
|
+
#write(client: Socket<ClientState>, line: string) {
|
|
95
|
+
if (client !== this.#client) return;
|
|
96
|
+
const bytes = Buffer.from(line);
|
|
97
|
+
if (client.data.pending) {
|
|
98
|
+
client.data.pending = Buffer.concat([client.data.pending, bytes]);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const written = client.write(bytes);
|
|
102
|
+
if (written < bytes.length) client.data.pending = bytes.subarray(Math.max(0, written));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#drain(client: Socket<ClientState>) {
|
|
106
|
+
const pending = client.data.pending;
|
|
107
|
+
if (!pending) return;
|
|
108
|
+
const written = client.write(pending);
|
|
109
|
+
client.data.pending = written < pending.length ? pending.subarray(Math.max(0, written)) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#close(client: Socket<ClientState>) {
|
|
113
|
+
if (client !== this.#client) return;
|
|
114
|
+
this.#client = null;
|
|
115
|
+
this.#host.attach(null);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { CodeAgentApprovalRequest, CodeAgentQuestion } from "akanjs/common";
|
|
4
|
+
|
|
5
|
+
interface SuspendedState {
|
|
6
|
+
question?: CodeAgentQuestion;
|
|
7
|
+
approvals: CodeAgentApprovalRequest[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a suspending profile is waiting on after its turn ended — the question it asked and the approvals it
|
|
12
|
+
* parked. The turn is gone, so nothing in memory holds them; this does, and on a file-backed session it writes
|
|
13
|
+
* them beside the session so a worker that restarts and resumes still knows what it asked.
|
|
14
|
+
*
|
|
15
|
+
* Taking an ask removes it, which is what makes an answer idempotent: a second tab or a retried frame finds
|
|
16
|
+
* nothing to take and is refused instead of opening a second turn.
|
|
17
|
+
*/
|
|
18
|
+
export class CodeAgentSuspended {
|
|
19
|
+
readonly #file: string | null;
|
|
20
|
+
#state: SuspendedState;
|
|
21
|
+
|
|
22
|
+
constructor(file: string | null) {
|
|
23
|
+
this.#file = file;
|
|
24
|
+
this.#state = CodeAgentSuspended.#read(file);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static fileOf(sessionDir: string | undefined, sessionId: string) {
|
|
28
|
+
return sessionDir ? path.join(sessionDir, `${sessionId}.suspended.json`) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get question() {
|
|
32
|
+
return this.#state.question;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get approvals(): readonly CodeAgentApprovalRequest[] {
|
|
36
|
+
return this.#state.approvals;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//* A new question replaces the old one: asking ends the turn, so the earlier question has nobody left to answer it.
|
|
40
|
+
setQuestion(question: CodeAgentQuestion) {
|
|
41
|
+
this.#state = { ...this.#state, question };
|
|
42
|
+
this.#save();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addApproval(request: CodeAgentApprovalRequest) {
|
|
46
|
+
this.#state = { ...this.#state, approvals: [...this.#state.approvals, request] };
|
|
47
|
+
this.#save();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
takeQuestion(questionId: string) {
|
|
51
|
+
const question = this.#state.question;
|
|
52
|
+
if (question?.questionId !== questionId) return undefined;
|
|
53
|
+
this.#state = { approvals: this.#state.approvals };
|
|
54
|
+
this.#save();
|
|
55
|
+
return question;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
takeApproval(approvalId: string) {
|
|
59
|
+
const request = this.#state.approvals.find((approval) => approval.approvalId === approvalId);
|
|
60
|
+
if (!request) return undefined;
|
|
61
|
+
this.#state = { ...this.#state, approvals: this.#state.approvals.filter((approval) => approval !== request) };
|
|
62
|
+
this.#save();
|
|
63
|
+
return request;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//* A prompt the user typed instead of answering moves the conversation on; the parked asks belong to a turn it replaced.
|
|
67
|
+
clear() {
|
|
68
|
+
if (!this.#state.question && !this.#state.approvals.length) return;
|
|
69
|
+
this.#state = { approvals: [] };
|
|
70
|
+
this.#save();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#save() {
|
|
74
|
+
if (!this.#file) return;
|
|
75
|
+
if (!this.#state.question && !this.#state.approvals.length) {
|
|
76
|
+
rmSync(this.#file, { force: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(path.dirname(this.#file), { recursive: true });
|
|
80
|
+
writeFileSync(this.#file, `${JSON.stringify(this.#state)}\n`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static #read(file: string | null): SuspendedState {
|
|
84
|
+
if (!file || !existsSync(file)) return { approvals: [] };
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<SuspendedState>;
|
|
87
|
+
return { ...(parsed.question ? { question: parsed.question } : {}), approvals: parsed.approvals ?? [] };
|
|
88
|
+
} catch {
|
|
89
|
+
return { approvals: [] };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -13,7 +13,7 @@ export interface CodeAgentModelRef {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
|
|
16
|
-
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-
|
|
16
|
+
export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Models the catalogue declares text-only that take a picture anyway.
|
|
@@ -22,7 +22,8 @@ const loaded = (root: string, name = "AGENTS.md"): AkanContextFile[] => [
|
|
|
22
22
|
{ path: path.join(root, name), content: "the workspace guide" },
|
|
23
23
|
];
|
|
24
24
|
|
|
25
|
-
const namesOf = (files: AkanContextFile[], root: string) =>
|
|
25
|
+
const namesOf = (files: AkanContextFile[], root: string) =>
|
|
26
|
+
files.map((file) => path.relative(root, file.path).split(path.sep).join("/"));
|
|
26
27
|
|
|
27
28
|
afterEach(() => {
|
|
28
29
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
@@ -292,6 +292,23 @@ describe("CodeAgentEventMapper", () => {
|
|
|
292
292
|
const frames = mapper.map({ type: "agent_end", messages: [{ stopReason: "aborted" }], willRetry: false } as never);
|
|
293
293
|
expect(frames).toEqual([{ type: "turn_end", turnId: "t1", stopReason: "aborted" }]);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
test("a compaction that failed or was cancelled carries that, not just its reason", () => {
|
|
297
|
+
const mapper = new CodeAgentEventMapper();
|
|
298
|
+
const end = (fields: object) =>
|
|
299
|
+
mapper.map({
|
|
300
|
+
type: "compaction_end",
|
|
301
|
+
reason: "threshold",
|
|
302
|
+
result: undefined,
|
|
303
|
+
willRetry: false,
|
|
304
|
+
...fields,
|
|
305
|
+
} as never);
|
|
306
|
+
expect(end({ aborted: false, errorMessage: "Auto-compaction failed: 529" })).toEqual([
|
|
307
|
+
{ type: "compaction", phase: "end", reason: "threshold", error: "Auto-compaction failed: 529" },
|
|
308
|
+
]);
|
|
309
|
+
expect(end({ aborted: true })).toEqual([{ type: "compaction", phase: "end", reason: "threshold", aborted: true }]);
|
|
310
|
+
expect(end({ aborted: false })).toEqual([{ type: "compaction", phase: "end", reason: "threshold" }]);
|
|
311
|
+
});
|
|
295
312
|
});
|
|
296
313
|
|
|
297
314
|
describe("CodeAgentStreamPrinter", () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AkanCodeServices } from "./AkanCodeServices";
|
|
6
|
+
import { akanCodeDefaultModel, akanCodeModel } from "./akanCodeModel";
|
|
7
|
+
import { CodeAgentProxy } from "./CodeAgentProxy";
|
|
8
|
+
|
|
9
|
+
const seen: { path: string; auth: string | null }[] = [];
|
|
10
|
+
let proxy: ReturnType<typeof Bun.serve>;
|
|
11
|
+
let home = "";
|
|
12
|
+
let realHome: string | undefined;
|
|
13
|
+
let realKey: string | undefined;
|
|
14
|
+
|
|
15
|
+
const chunk = (delta: object, finish: string | null = null) =>
|
|
16
|
+
`data: ${JSON.stringify({
|
|
17
|
+
id: "c1",
|
|
18
|
+
object: "chat.completion.chunk",
|
|
19
|
+
created: 0,
|
|
20
|
+
model: akanCodeDefaultModel.id,
|
|
21
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
22
|
+
})}\n\n`;
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
home = mkdtempSync(path.join(tmpdir(), "akan-code-proxy-test-"));
|
|
26
|
+
realHome = process.env.AKAN_CODE_HOME;
|
|
27
|
+
realKey = process.env.DEEPSEEK_API_KEY;
|
|
28
|
+
process.env.AKAN_CODE_HOME = home;
|
|
29
|
+
process.env.DEEPSEEK_API_KEY = "real-provider-key-must-not-leave";
|
|
30
|
+
proxy = Bun.serve({
|
|
31
|
+
port: 0,
|
|
32
|
+
fetch: (req) => {
|
|
33
|
+
seen.push({ path: new URL(req.url).pathname, auth: req.headers.get("authorization") });
|
|
34
|
+
const body = `${chunk({ role: "assistant", content: "pong" })}${chunk({}, "stop")}data: [DONE]\n\n`;
|
|
35
|
+
return new Response(body, { headers: { "content-type": "text/event-stream" } });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
proxy.stop(true);
|
|
42
|
+
rmSync(home, { recursive: true, force: true });
|
|
43
|
+
if (realHome === undefined) delete process.env.AKAN_CODE_HOME;
|
|
44
|
+
else process.env.AKAN_CODE_HOME = realHome;
|
|
45
|
+
if (realKey === undefined) delete process.env.DEEPSEEK_API_KEY;
|
|
46
|
+
else process.env.DEEPSEEK_API_KEY = realKey;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("CodeAgentProxy", () => {
|
|
50
|
+
test("sends every request to the proxy with the token read from its file at that moment", async () => {
|
|
51
|
+
const tokenFile = path.join(home, "token");
|
|
52
|
+
writeFileSync(tokenFile, "session-token-1\n");
|
|
53
|
+
const codeProxy = new CodeAgentProxy({
|
|
54
|
+
baseUrl: `http://127.0.0.1:${proxy.port}/llm/{provider}/v1`,
|
|
55
|
+
providers: [akanCodeDefaultModel.provider],
|
|
56
|
+
tokenFile,
|
|
57
|
+
});
|
|
58
|
+
const runtime = await AkanCodeServices.runtime(home, codeProxy);
|
|
59
|
+
const model = await akanCodeModel(runtime);
|
|
60
|
+
expect(model?.provider).toBe("deepseek");
|
|
61
|
+
const context = { messages: [{ role: "user" as const, content: "ping", timestamp: Date.now() }] };
|
|
62
|
+
if (!model) throw new Error("no model");
|
|
63
|
+
const first = await runtime.completeSimple(model, context);
|
|
64
|
+
writeFileSync(tokenFile, "session-token-2\n");
|
|
65
|
+
await runtime.completeSimple(model, context);
|
|
66
|
+
expect(first.content).toEqual([expect.objectContaining({ type: "text", text: "pong" })]);
|
|
67
|
+
expect(seen.map((request) => request.auth)).toEqual(["Bearer session-token-1", "Bearer session-token-2"]);
|
|
68
|
+
expect(seen.every((request) => request.path.startsWith("/llm/deepseek/v1/"))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("refuses to start with a proxy URL and no token source", () => {
|
|
72
|
+
expect(() => new CodeAgentProxy({ baseUrl: "https://proxy", providers: ["deepseek"] })).toThrow("TOKEN");
|
|
73
|
+
});
|
|
74
|
+
});
|