@akanjs/devkit 3.0.0-beta.17 → 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 +5 -4
- 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 +33 -6
- package/artifact/implicitRootLayout.test.ts +6 -11
- package/artifact/implicitRootLayout.ts +0 -15
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/biome.base.json +6 -1
- package/capacitorApp.test.ts +28 -2
- package/capacitorApp.ts +25 -4
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +38 -12
- package/codeAgent/agent/CodeAgent.ts +94 -30
- 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/akanCodePaths.ts +23 -3
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/akanSystemPrompt.ts +5 -0
- 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/skills/akan-code/SKILL.md +122 -0
- package/codeAgent/skills/akan-module/SKILL.md +2 -2
- package/codeAgent/skills/akan-store/SKILL.md +6 -2
- package/codeAgent/tools/AkanCodePlugins.ts +19 -3
- package/codeAgent/tools/AskToolPack.ts +111 -0
- package/codeAgent/tools/McpClient.ts +51 -5
- package/codeAgent/tools/McpOAuth.ts +268 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +77 -0
- package/codeAgent/tools/McpServerConfig.ts +69 -34
- package/codeAgent/tools/McpSignIn.ts +161 -0
- package/codeAgent/tools/McpTokenStore.ts +92 -0
- package/codeAgent/tools/McpToolPack.ts +16 -3
- package/codeAgent/tools/askToolPack.test.ts +119 -0
- package/codeAgent/tools/mcpOAuth.test.ts +324 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +106 -12
- package/commandDecorators/command.ts +2 -0
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/helpFormatter.ts +2 -1
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +18 -46
- 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/index.ts +0 -1
- 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/prompter.ts +0 -7
- package/qualityScanner.test.ts +3 -2
- package/qualityScanner.ts +1 -1
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +0 -12
- package/scanInfo.ts +5 -2
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- package/workflow/executor.ts +52 -0
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +19 -13
- package/guideline.ts +0 -19
package/cloud/constants.ts
CHANGED
|
@@ -24,13 +24,36 @@ export interface RemoteEnvServerConfig {
|
|
|
24
24
|
username?: string;
|
|
25
25
|
port?: number;
|
|
26
26
|
}
|
|
27
|
+
export type PlatformGatePolicy = "gate" | "warn";
|
|
28
|
+
export interface LinuxTestTargetConfig {
|
|
29
|
+
cpus?: number;
|
|
30
|
+
memory?: string;
|
|
31
|
+
policy?: PlatformGatePolicy;
|
|
32
|
+
}
|
|
33
|
+
export interface WindowsTestTargetConfig {
|
|
34
|
+
host: string;
|
|
35
|
+
user: string;
|
|
36
|
+
identityFile: string;
|
|
37
|
+
knownHostsFile?: string;
|
|
38
|
+
utmVm?: string;
|
|
39
|
+
workRoot?: string;
|
|
40
|
+
tolerateScriptFailures?: string[];
|
|
41
|
+
policy?: PlatformGatePolicy;
|
|
42
|
+
greenStreak?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface TestTargetsConfig {
|
|
45
|
+
linux?: LinuxTestTargetConfig;
|
|
46
|
+
windows?: WindowsTestTargetConfig;
|
|
47
|
+
}
|
|
27
48
|
export interface AkanGlobalConfig {
|
|
28
49
|
cloudHost: { [key: string]: HostConfigDto };
|
|
29
50
|
remoteEnvServers: Record<string, RemoteEnvServerConfig>;
|
|
51
|
+
testTargets: TestTargetsConfig;
|
|
30
52
|
}
|
|
31
53
|
export const defaultAkanGlobalConfig: AkanGlobalConfig = {
|
|
32
54
|
cloudHost: {},
|
|
33
55
|
remoteEnvServers: {},
|
|
56
|
+
testTargets: {},
|
|
34
57
|
};
|
|
35
58
|
|
|
36
59
|
export interface AccessTokenDto {
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type HostConfig,
|
|
13
13
|
type HostConfigDto,
|
|
14
14
|
type RemoteEnvServerConfig,
|
|
15
|
+
type TestTargetsConfig,
|
|
15
16
|
} from "./constants";
|
|
16
17
|
|
|
17
18
|
export class GlobalConfig {
|
|
@@ -27,6 +28,7 @@ export class GlobalConfig {
|
|
|
27
28
|
...akanConfig,
|
|
28
29
|
cloudHost: akanConfig.cloudHost ?? defaultAkanGlobalConfig.cloudHost,
|
|
29
30
|
remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
|
|
31
|
+
testTargets: akanConfig.testTargets ?? defaultAkanGlobalConfig.testTargets,
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
34
|
/**
|
|
@@ -71,6 +73,17 @@ export class GlobalConfig {
|
|
|
71
73
|
remoteEnvServers,
|
|
72
74
|
});
|
|
73
75
|
}
|
|
76
|
+
static async getTestTargets(): Promise<TestTargetsConfig> {
|
|
77
|
+
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
78
|
+
return akanConfig.testTargets;
|
|
79
|
+
}
|
|
80
|
+
static async setTestTargets(testTargets: TestTargetsConfig) {
|
|
81
|
+
const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
|
|
82
|
+
await GlobalConfig.#setAkanGlobalConfig({
|
|
83
|
+
...akanConfig,
|
|
84
|
+
testTargets: { ...akanConfig.testTargets, ...testTargets },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
74
87
|
static needRefreshToken(accessToken: AccessToken): boolean {
|
|
75
88
|
return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
|
|
76
89
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
5
|
import type { CodeAgentProfile } from "akanjs/common";
|
|
4
6
|
import { AkanEnvKeys } from "../tools/AkanEnvKeys";
|
|
5
7
|
import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
|
|
6
8
|
import { akanCodePaths } from "./akanCodePaths";
|
|
7
9
|
import { akanSystemPrompt } from "./akanSystemPrompt";
|
|
10
|
+
import type { CodeAgentProxy } from "./CodeAgentProxy";
|
|
8
11
|
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
9
12
|
|
|
10
13
|
export interface AkanCodeServicesOptions {
|
|
@@ -32,6 +35,10 @@ export class AkanCodeServices {
|
|
|
32
35
|
enableInstallTelemetry: false,
|
|
33
36
|
enableAnalytics: false,
|
|
34
37
|
quietStartup: true,
|
|
38
|
+
// Twice the engine's 16,384. The reserve is all the room the response at the threshold gets — a request
|
|
39
|
+
// clamps its output to the window less the prompt less 4,096 — and 80% of it caps the summary. At 16k a
|
|
40
|
+
// reasoning model stops mid-thought there, and the recovery re-sends a near-full window to retry the turn.
|
|
41
|
+
compaction: { reserveTokens: 32_768 },
|
|
35
42
|
});
|
|
36
43
|
}
|
|
37
44
|
|
|
@@ -42,8 +49,18 @@ export class AkanCodeServices {
|
|
|
42
49
|
* locks and rewrites that file itself, so a second `akan code` refreshing an OAuth token at the same moment
|
|
43
50
|
* cannot clobber the first. Nothing lands in `~/.pi/`, and a user who removes akan takes their keys with them.
|
|
44
51
|
*/
|
|
45
|
-
static async runtime(workspaceRoot: string) {
|
|
52
|
+
static async runtime(workspaceRoot: string, proxy: CodeAgentProxy | null = null) {
|
|
46
53
|
mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
|
|
54
|
+
if (proxy) {
|
|
55
|
+
//* Behind a proxy neither `auth.json` nor an env key may reach a request, so credentials live in a throwaway
|
|
56
|
+
//* file and the only key the engine ever sends is the proxy token.
|
|
57
|
+
const runtime = await ModelRuntime.create({
|
|
58
|
+
authPath: path.join(mkdtempSync(path.join(tmpdir(), "akan-code-proxy-")), "auth.json"),
|
|
59
|
+
modelsPath: akanCodePaths.modelsFile(),
|
|
60
|
+
});
|
|
61
|
+
await proxy.apply(runtime);
|
|
62
|
+
return runtime;
|
|
63
|
+
}
|
|
47
64
|
const runtime = await ModelRuntime.create({
|
|
48
65
|
authPath: akanCodePaths.authFile(),
|
|
49
66
|
modelsPath: akanCodePaths.modelsFile(),
|
|
@@ -53,7 +70,9 @@ export class AkanCodeServices {
|
|
|
53
70
|
}
|
|
54
71
|
|
|
55
72
|
static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
|
|
56
|
-
|
|
73
|
+
//* `remote` has no store of its own yet, so it keeps a file session rather than none: a pod's worker is long-lived
|
|
74
|
+
//* and its suspended asks and resume need the file more than a laptop does.
|
|
75
|
+
if (profile.session.store === "memory") return SessionManager.inMemory(cwd);
|
|
57
76
|
const dir = akanCodePaths.sessionsDir(workspaceRoot);
|
|
58
77
|
mkdirSync(dir, { recursive: true });
|
|
59
78
|
const manager = SessionManager.create(cwd, dir);
|
|
@@ -66,22 +85,29 @@ export class AkanCodeServices {
|
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
/**
|
|
69
|
-
* The akan skill set, then the
|
|
88
|
+
* The akan skill set, then the person's own, then the workspace's.
|
|
70
89
|
*
|
|
71
|
-
*
|
|
90
|
+
* `~/.akan/code/skills` is read because a skill somebody wrote for themselves is theirs, not one checkout's,
|
|
91
|
+
* and having to copy it into every repo is the same friction that makes people stop writing them. It is
|
|
92
|
+
* text the model may read, never code that runs — which is why extensions stay workspace-only below.
|
|
93
|
+
*
|
|
94
|
+
* A path that does not exist is a reported load error rather than a skip, so each is checked first.
|
|
72
95
|
*/
|
|
73
96
|
static #skillPaths(profile: CodeAgentProfile, workspaceRoot: string) {
|
|
74
97
|
if (!profile.context.skills) return [];
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
(
|
|
78
|
-
|
|
98
|
+
const dirs = [
|
|
99
|
+
akanCodePaths.builtinSkillsDir(),
|
|
100
|
+
akanCodePaths.globalSkillsDir(),
|
|
101
|
+
akanCodePaths.skillsDir(workspaceRoot),
|
|
102
|
+
];
|
|
103
|
+
return dirs.filter((dir): dir is string => !!dir && existsSync(dir));
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
/**
|
|
82
|
-
* What the engine's resource loader may discover. Extensions
|
|
83
|
-
*
|
|
84
|
-
*
|
|
107
|
+
* What the engine's resource loader may discover. Extensions come from the workspace and nowhere else: a
|
|
108
|
+
* globally installed extension is code that runs in every checkout, so an agent would behave differently in
|
|
109
|
+
* two clones of the same repo for reasons nothing in the repo explains. Skills are text and are read from
|
|
110
|
+
* the home directory too — see `#skillPaths`.
|
|
85
111
|
*/
|
|
86
112
|
static resourceOptions(options: AkanCodeServicesOptions) {
|
|
87
113
|
// A path that does not exist is reported as a load error rather than skipped, and these two are optional.
|
|
@@ -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,
|
|
@@ -15,6 +14,7 @@ import {
|
|
|
15
14
|
type CodeAgentImage,
|
|
16
15
|
type CodeAgentMcpStatus,
|
|
17
16
|
type CodeAgentProfile,
|
|
17
|
+
type CodeAgentProviderInfo,
|
|
18
18
|
type CodeAgentQuestion,
|
|
19
19
|
type CodeAgentSessionInfo,
|
|
20
20
|
type CodeAgentState,
|
|
@@ -36,6 +36,8 @@ import { akanCodePaths } from "./akanCodePaths";
|
|
|
36
36
|
import { CodeAgentAsks } from "./CodeAgentAsks";
|
|
37
37
|
import { CodeAgentEventMapper } from "./CodeAgentEventMapper";
|
|
38
38
|
import { CodeAgentGate } from "./CodeAgentGate";
|
|
39
|
+
import { CodeAgentProxy } from "./CodeAgentProxy";
|
|
40
|
+
import { CodeAgentSuspended } from "./CodeAgentSuspended";
|
|
39
41
|
import { CodeAgentUi } from "./CodeAgentUi";
|
|
40
42
|
import { CodeMailbox } from "./CodeMailbox";
|
|
41
43
|
import { CodeSessionFork } from "./CodeSessionFork";
|
|
@@ -76,6 +78,7 @@ export class CodeAgent {
|
|
|
76
78
|
readonly #gate: CodeAgentGate;
|
|
77
79
|
readonly #mapper = new CodeAgentEventMapper();
|
|
78
80
|
readonly #asks = new CodeAgentAsks();
|
|
81
|
+
#suspended = new CodeAgentSuspended(null);
|
|
79
82
|
readonly #listeners = new Set<(event: CodeAgentEvent) => void>();
|
|
80
83
|
#session: AgentSession | undefined;
|
|
81
84
|
#disposePlugins: (() => void) | undefined;
|
|
@@ -105,9 +108,9 @@ export class CodeAgent {
|
|
|
105
108
|
const cwd = options.cwd ?? workspaceRoot;
|
|
106
109
|
const agent = new CodeAgent(options.profile);
|
|
107
110
|
const settingsManager = AkanCodeServices.settings();
|
|
108
|
-
const modelRuntime = await AkanCodeServices.runtime(workspaceRoot);
|
|
111
|
+
const modelRuntime = await AkanCodeServices.runtime(workspaceRoot, CodeAgentProxy.fromEnv(options.profile));
|
|
109
112
|
const model = await akanCodeModel(modelRuntime, options.model);
|
|
110
|
-
const compaction =
|
|
113
|
+
const compaction = settingsManager.getCompactionSettings(model);
|
|
111
114
|
const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
|
|
112
115
|
|
|
113
116
|
const akan = await AkanCodePlugins.build({
|
|
@@ -121,6 +124,7 @@ export class CodeAgent {
|
|
|
121
124
|
mailbox: () => agent.#mailbox,
|
|
122
125
|
onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
|
|
123
126
|
onAgents: (agents) => agent.#emit({ type: "subagent", agents }),
|
|
127
|
+
onAsk: (question) => agent.ask(question),
|
|
124
128
|
});
|
|
125
129
|
|
|
126
130
|
const resourceLoader = new DefaultResourceLoader({
|
|
@@ -151,9 +155,11 @@ export class CodeAgent {
|
|
|
151
155
|
agent.#disposePlugins = akan.dispose;
|
|
152
156
|
agent.#mcp = akan.mcp;
|
|
153
157
|
agent.#workspaceRoot = workspaceRoot;
|
|
154
|
-
if (options.profile.session.store
|
|
158
|
+
if (options.profile.session.store !== "memory") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
|
|
155
159
|
await agent.#attach(session, options.mode ?? "print");
|
|
156
|
-
|
|
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);
|
|
157
163
|
for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
|
|
158
164
|
return agent;
|
|
159
165
|
}
|
|
@@ -260,6 +266,10 @@ export class CodeAgent {
|
|
|
260
266
|
announce() {
|
|
261
267
|
this.#emit({ type: "session", info: this.info });
|
|
262
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 });
|
|
263
273
|
}
|
|
264
274
|
|
|
265
275
|
/**
|
|
@@ -281,6 +291,7 @@ export class CodeAgent {
|
|
|
281
291
|
const text = CodeAgent.#textOf(message.content);
|
|
282
292
|
if (text) this.#emit({ type: "message", turnId, role: message.role, text });
|
|
283
293
|
}
|
|
294
|
+
this.#emitContextNow();
|
|
284
295
|
}
|
|
285
296
|
|
|
286
297
|
static #textOf(content: unknown) {
|
|
@@ -343,10 +354,13 @@ export class CodeAgent {
|
|
|
343
354
|
return true;
|
|
344
355
|
}
|
|
345
356
|
if (this.#profile.interaction.question !== "suspend") return false;
|
|
346
|
-
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 });
|
|
347
361
|
// The answer has to reach the model as prose: compaction and the next turn read message content only, so
|
|
348
362
|
// an answer that exists solely as structure is one the agent will not remember being given.
|
|
349
|
-
await this.prompt(`The user answered: ${
|
|
363
|
+
await this.prompt(`The user answered: ${suspendedRendered}\nContinue the task.`);
|
|
350
364
|
return true;
|
|
351
365
|
}
|
|
352
366
|
|
|
@@ -356,6 +370,7 @@ export class CodeAgent {
|
|
|
356
370
|
return true;
|
|
357
371
|
}
|
|
358
372
|
if (this.#profile.interaction.approval !== "suspend") return false;
|
|
373
|
+
if (!this.#suspended.takeApproval(approvalId)) return false;
|
|
359
374
|
this.#emit({ type: "approval_resolved", approvalId, approved });
|
|
360
375
|
if (approved) await this.prompt("The user approved the pending action. Retry it and continue.");
|
|
361
376
|
return true;
|
|
@@ -400,6 +415,33 @@ export class CodeAgent {
|
|
|
400
415
|
this.#emit({ type: "session", info: this.info });
|
|
401
416
|
}
|
|
402
417
|
|
|
418
|
+
/**
|
|
419
|
+
* The model catalogue, as the two questions a person actually asks of it.
|
|
420
|
+
*
|
|
421
|
+
* Built from the registry rather than `getAvailable()` so it needs no await and can answer about a provider
|
|
422
|
+
* nobody has a key for: "what else could I run" is most of why the list is opened, and a list of only what
|
|
423
|
+
* is already reachable cannot answer it.
|
|
424
|
+
*/
|
|
425
|
+
catalogue(): CodeAgentProviderInfo[] {
|
|
426
|
+
const runtime = this.#session?.modelRuntime;
|
|
427
|
+
if (!runtime) return [];
|
|
428
|
+
const current = this.#session?.model;
|
|
429
|
+
return runtime
|
|
430
|
+
.getProviders()
|
|
431
|
+
.map((provider) => ({
|
|
432
|
+
id: provider.id,
|
|
433
|
+
name: provider.name ?? provider.id,
|
|
434
|
+
authorized: runtime.hasConfiguredAuth(provider.id),
|
|
435
|
+
models: (runtime.getModels(provider.id) ?? []).map((model) => ({
|
|
436
|
+
id: model.id,
|
|
437
|
+
name: model.name,
|
|
438
|
+
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
|
|
439
|
+
current: model.provider === current?.provider && model.id === current?.id,
|
|
440
|
+
})),
|
|
441
|
+
}))
|
|
442
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
443
|
+
}
|
|
444
|
+
|
|
403
445
|
async setModel(ref: CodeAgentModelRef) {
|
|
404
446
|
const session = this.#require();
|
|
405
447
|
const model = await akanCodeModel(session.modelRuntime, ref);
|
|
@@ -461,6 +503,17 @@ export class CodeAgent {
|
|
|
461
503
|
*/
|
|
462
504
|
#emitContextUsage(eventType: string) {
|
|
463
505
|
if (eventType !== "agent_end" && eventType !== "compaction_end") return;
|
|
506
|
+
this.#emitContextNow();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The same frame, on demand, for the one moment the window is already full and no turn has run.
|
|
511
|
+
*
|
|
512
|
+
* A resumed session restores a conversation the model is carrying and the host has never seen, so without
|
|
513
|
+
* this the status line offers the declared window and no share of it until the next turn ends — a session
|
|
514
|
+
* reopened at 80% looks identical to one reopened empty.
|
|
515
|
+
*/
|
|
516
|
+
#emitContextNow() {
|
|
464
517
|
const usage = this.#session?.getContextUsage();
|
|
465
518
|
// `== null`, not `=== null`: the field is typed `number | null`, and a strict null check sails straight
|
|
466
519
|
// past an `undefined` the way the mirror of this bug does — which would emit `used: undefined`.
|
|
@@ -520,42 +573,53 @@ export class CodeAgent {
|
|
|
520
573
|
// Nobody to ask means nobody to refuse: a pod would otherwise deny every write it was created to make.
|
|
521
574
|
if (!this.#profile.ui.canPrompt) return true;
|
|
522
575
|
const approvalId = this.#asks.nextId("a");
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
},
|
|
532
|
-
});
|
|
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 });
|
|
533
584
|
if (this.#profile.interaction.approval === "suspend") {
|
|
585
|
+
this.#suspended.addApproval(request);
|
|
534
586
|
this.#mapper.noteOutcome("awaiting");
|
|
535
587
|
return false;
|
|
536
588
|
}
|
|
537
589
|
return await this.#asks.openApproval(approvalId);
|
|
538
590
|
}
|
|
539
591
|
|
|
592
|
+
/**
|
|
593
|
+
* Puts one question on the wire and waits for its answer, rendered as the labels a person chose.
|
|
594
|
+
*
|
|
595
|
+
* The single way anything asks: the engine's own UI port and the `ask_user` tool both land here, so the
|
|
596
|
+
* suspend path, the profile's refusal to prompt at all, and the id the host answers against cannot disagree
|
|
597
|
+
* between them. An empty answer is a skip, which is a real answer and not a failure.
|
|
598
|
+
*/
|
|
599
|
+
async ask(spec: Omit<CodeAgentQuestion, "questionId">) {
|
|
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
|
+
}
|
|
605
|
+
this.#emit({ type: "question", question });
|
|
606
|
+
if (this.#profile.interaction.question === "suspend") {
|
|
607
|
+
this.#suspended.setQuestion(question);
|
|
608
|
+
this.#mapper.noteOutcome("awaiting");
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
return (await this.#asks.openQuestion(question)) || undefined;
|
|
612
|
+
}
|
|
613
|
+
|
|
540
614
|
#ui() {
|
|
541
615
|
return new CodeAgentUi({
|
|
542
|
-
ask: async (prompt, kind, choices) =>
|
|
543
|
-
|
|
544
|
-
const questionId = this.#asks.nextId("q");
|
|
545
|
-
const question: CodeAgentQuestion = {
|
|
546
|
-
questionId,
|
|
616
|
+
ask: async (prompt, kind, choices) =>
|
|
617
|
+
await this.ask({
|
|
547
618
|
prompt,
|
|
548
619
|
kind,
|
|
549
620
|
...(choices ? { options: choices.map((label) => ({ key: label, label })) } : {}),
|
|
550
621
|
freeText: kind === "text",
|
|
551
|
-
}
|
|
552
|
-
this.#emit({ type: "question", question });
|
|
553
|
-
if (this.#profile.interaction.question === "suspend") {
|
|
554
|
-
this.#mapper.noteOutcome("awaiting");
|
|
555
|
-
return undefined;
|
|
556
|
-
}
|
|
557
|
-
return (await this.#asks.openQuestion(question)) || undefined;
|
|
558
|
-
},
|
|
622
|
+
}),
|
|
559
623
|
notify: (level, message) => this.#emit({ type: "notice", level, message }),
|
|
560
624
|
});
|
|
561
625
|
}
|
|
@@ -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
|
}
|