@akanjs/devkit 3.0.0-beta.14 → 3.0.0-beta.16
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/README.md +1 -0
- package/codeAgent/agent/AkanAuthStorageBackend.ts +105 -0
- package/codeAgent/agent/AkanCodeServices.ts +104 -0
- package/codeAgent/agent/CodeAgent.ts +543 -0
- package/codeAgent/agent/CodeAgentAsks.ts +87 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +260 -0
- package/codeAgent/agent/CodeAgentGate.ts +119 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +96 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +93 -0
- package/codeAgent/agent/CodeAgentUi.ts +52 -0
- package/codeAgent/agent/CodeMailbox.ts +175 -0
- package/codeAgent/agent/CodeSessionFork.ts +71 -0
- package/codeAgent/agent/CodeSessionIndex.ts +104 -0
- package/codeAgent/agent/SubagentPool.ts +173 -0
- package/codeAgent/agent/akanCodeModel.ts +81 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanSystemPrompt.ts +49 -0
- package/codeAgent/agent/codeAgentContract.test.ts +529 -0
- package/codeAgent/agent/codeMailbox.test.ts +80 -0
- package/codeAgent/agent/codeSessionFork.test.ts +60 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +72 -0
- package/codeAgent/agent/consoleToStderr.ts +18 -0
- package/codeAgent/feedback/DevLogFeedback.ts +97 -0
- package/codeAgent/feedback/PreviewChrome.ts +106 -0
- package/codeAgent/feedback/PreviewFeedback.ts +53 -0
- package/codeAgent/feedback/PreviewView.ts +107 -0
- package/codeAgent/feedback/TurnFeedback.ts +113 -0
- package/codeAgent/feedback/VerifyFeedback.ts +46 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +43 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +63 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +62 -0
- package/codeAgent/tools/AkanCodePlugins.ts +135 -0
- package/codeAgent/tools/AkanEditScope.ts +58 -0
- package/codeAgent/tools/AkanEnvKeys.ts +56 -0
- package/codeAgent/tools/AkanToolPack.ts +114 -0
- package/codeAgent/tools/AkanVerifier.ts +55 -0
- package/codeAgent/tools/MailToolPack.ts +85 -0
- package/codeAgent/tools/McpClient.ts +162 -0
- package/codeAgent/tools/McpToolPack.ts +134 -0
- package/codeAgent/tools/SessionToolPack.ts +101 -0
- package/codeAgent/tools/WebToolPack.ts +153 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +476 -0
- package/executors.ts +5 -0
- package/index.ts +2 -0
- package/package.json +8 -2
- package/piDependencyPins.test.ts +55 -0
- package/stripAnsi.ts +3 -0
- package/testHelpers.ts +179 -0
- package/types.ts +1 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentSession,
|
|
3
|
+
createAgentSession,
|
|
4
|
+
DEFAULT_COMPACTION_SETTINGS,
|
|
5
|
+
DefaultResourceLoader,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type InlineExtension,
|
|
8
|
+
type ToolDefinition,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
type CodeAgentAnswer,
|
|
12
|
+
type CodeAgentEffort,
|
|
13
|
+
type CodeAgentEvent,
|
|
14
|
+
type CodeAgentEventBody,
|
|
15
|
+
type CodeAgentImage,
|
|
16
|
+
type CodeAgentProfile,
|
|
17
|
+
type CodeAgentQuestion,
|
|
18
|
+
type CodeAgentSessionInfo,
|
|
19
|
+
type CodeAgentState,
|
|
20
|
+
codeAgentClip,
|
|
21
|
+
codeAgentLabelChars,
|
|
22
|
+
codeAgentRenderAnswer,
|
|
23
|
+
codeAgentSessionName,
|
|
24
|
+
} from "akanjs/common";
|
|
25
|
+
import type { Workspace } from "../../commandDecorators";
|
|
26
|
+
import { AkanCodePlugins } from "../tools/AkanCodePlugins";
|
|
27
|
+
import { AkanCodeServices } from "./AkanCodeServices";
|
|
28
|
+
import {
|
|
29
|
+
akanCodeModel,
|
|
30
|
+
akanCodeModelSupportsImages,
|
|
31
|
+
akanCodeModelWarnings,
|
|
32
|
+
type CodeAgentModelRef,
|
|
33
|
+
} from "./akanCodeModel";
|
|
34
|
+
import { akanCodePaths } from "./akanCodePaths";
|
|
35
|
+
import { CodeAgentAsks } from "./CodeAgentAsks";
|
|
36
|
+
import { CodeAgentEventMapper } from "./CodeAgentEventMapper";
|
|
37
|
+
import { CodeAgentGate } from "./CodeAgentGate";
|
|
38
|
+
import { CodeAgentUi } from "./CodeAgentUi";
|
|
39
|
+
import { CodeMailbox } from "./CodeMailbox";
|
|
40
|
+
import { CodeSessionFork } from "./CodeSessionFork";
|
|
41
|
+
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the engine calls a run mode. It is not re-exported from the package entry, so the literal set is
|
|
45
|
+
* restated here; it is the whole type, and a mismatch is a type error at the one call site that uses it.
|
|
46
|
+
*/
|
|
47
|
+
export type CodeAgentHostMode = "tui" | "rpc" | "json" | "print";
|
|
48
|
+
|
|
49
|
+
export interface CodeAgentOptions {
|
|
50
|
+
workspace: Workspace;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
profile: CodeAgentProfile;
|
|
53
|
+
model?: CodeAgentModelRef;
|
|
54
|
+
/** Apps whose dev server and preview the turn-end feedback loop watches. */
|
|
55
|
+
apps?: string[];
|
|
56
|
+
/** Extra capability packs, layered on top of the profile gate and the akan pack. */
|
|
57
|
+
extensions?: InlineExtension[];
|
|
58
|
+
customTools?: ToolDefinition[];
|
|
59
|
+
/** `print` for a one-shot stream, `rpc` when a host drives this process over stdio. */
|
|
60
|
+
mode?: CodeAgentHostMode;
|
|
61
|
+
/** How many `task` tools deep this agent already is. A sub-agent is created one level down. */
|
|
62
|
+
depth?: number;
|
|
63
|
+
/** Id of a stored session to continue instead of opening a new one. */
|
|
64
|
+
resume?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The code agent core: one engine session, gated by a profile, emitting the akan wire.
|
|
69
|
+
*
|
|
70
|
+
* Nothing here knows what a host is. A host receives events, sends commands and injects a profile — which is
|
|
71
|
+
* what lets one core serve a terminal, an RPC child and a browser without three copies of the loop.
|
|
72
|
+
*/
|
|
73
|
+
export class CodeAgent {
|
|
74
|
+
readonly #profile: CodeAgentProfile;
|
|
75
|
+
readonly #gate: CodeAgentGate;
|
|
76
|
+
readonly #mapper = new CodeAgentEventMapper();
|
|
77
|
+
readonly #asks = new CodeAgentAsks();
|
|
78
|
+
readonly #listeners = new Set<(event: CodeAgentEvent) => void>();
|
|
79
|
+
#session: AgentSession | undefined;
|
|
80
|
+
#disposePlugins: (() => void) | undefined;
|
|
81
|
+
#seq = 0;
|
|
82
|
+
#disposed = false;
|
|
83
|
+
#sessionDir: string | undefined;
|
|
84
|
+
#mailbox: CodeMailbox | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* The frames of the turn in flight, so a host that reconnects mid-turn can be handed what it missed.
|
|
87
|
+
*
|
|
88
|
+
* Bounded and cleared at `idle`: the completed messages of an earlier turn are in the transcript, and the
|
|
89
|
+
* only thing that exists nowhere else is the bubble still being written.
|
|
90
|
+
*/
|
|
91
|
+
readonly #replay: CodeAgentEvent[] = [];
|
|
92
|
+
#replayFrom = 0;
|
|
93
|
+
#lastContextTokens: number | undefined;
|
|
94
|
+
|
|
95
|
+
private constructor(profile: CodeAgentProfile) {
|
|
96
|
+
this.#profile = profile;
|
|
97
|
+
this.#gate = new CodeAgentGate(profile);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static async create(options: CodeAgentOptions) {
|
|
101
|
+
const workspaceRoot = options.workspace.workspaceRoot;
|
|
102
|
+
const cwd = options.cwd ?? workspaceRoot;
|
|
103
|
+
const agent = new CodeAgent(options.profile);
|
|
104
|
+
const settingsManager = AkanCodeServices.settings();
|
|
105
|
+
const authStorage = AkanCodeServices.auth(workspaceRoot);
|
|
106
|
+
const modelRegistry = AkanCodeServices.models(authStorage);
|
|
107
|
+
const model = await akanCodeModel(modelRegistry, options.model);
|
|
108
|
+
const compaction = DEFAULT_COMPACTION_SETTINGS;
|
|
109
|
+
const modelWarnings = akanCodeModelWarnings(model, compaction.reserveTokens + compaction.keepRecentTokens);
|
|
110
|
+
|
|
111
|
+
const akan = await AkanCodePlugins.build({
|
|
112
|
+
workspace: options.workspace,
|
|
113
|
+
cwd,
|
|
114
|
+
profile: options.profile,
|
|
115
|
+
apps: options.apps ?? [],
|
|
116
|
+
canSeeImages: akanCodeModelSupportsImages(model),
|
|
117
|
+
depth: options.depth ?? 0,
|
|
118
|
+
currentSessionId: () => agent.sessionId,
|
|
119
|
+
mailbox: () => agent.#mailbox,
|
|
120
|
+
onNotice: (message) => agent.#emit({ type: "notice", level: "warning", message }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
124
|
+
cwd,
|
|
125
|
+
agentDir: akanCodePaths.globalDir(),
|
|
126
|
+
settingsManager,
|
|
127
|
+
...AkanCodeServices.resourceOptions({
|
|
128
|
+
workspaceRoot,
|
|
129
|
+
cwd,
|
|
130
|
+
profile: options.profile,
|
|
131
|
+
extensions: [agent.#gateExtension(), ...akan.extensions, ...(options.extensions ?? [])],
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
await resourceLoader.reload();
|
|
135
|
+
|
|
136
|
+
const { session } = await createAgentSession({
|
|
137
|
+
cwd,
|
|
138
|
+
agentDir: akanCodePaths.globalDir(),
|
|
139
|
+
authStorage,
|
|
140
|
+
modelRegistry,
|
|
141
|
+
settingsManager,
|
|
142
|
+
resourceLoader,
|
|
143
|
+
model,
|
|
144
|
+
sessionManager: AkanCodeServices.sessions(workspaceRoot, cwd, options.profile, options.resume),
|
|
145
|
+
noTools: "builtin",
|
|
146
|
+
tools: [...options.profile.tools.builtin, ...akan.toolNames],
|
|
147
|
+
customTools: options.customTools,
|
|
148
|
+
});
|
|
149
|
+
agent.#disposePlugins = akan.dispose;
|
|
150
|
+
if (options.profile.session.store === "file") agent.#sessionDir = akanCodePaths.sessionsDir(workspaceRoot);
|
|
151
|
+
await agent.#attach(session, options.mode ?? "print");
|
|
152
|
+
if (agent.#sessionDir) agent.#openMail(akanCodePaths.mailDir(workspaceRoot), cwd);
|
|
153
|
+
for (const message of modelWarnings) agent.#emit({ type: "notice", level: "warning", message });
|
|
154
|
+
return agent;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get profile() {
|
|
158
|
+
return this.#profile;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether the model can be handed a picture at all, which decides whether offering to attach one is honest. */
|
|
162
|
+
get canSeeImages() {
|
|
163
|
+
const model = this.#session?.model;
|
|
164
|
+
return model ? akanCodeModelSupportsImages(model) : false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The workspace's stored sessions, newest first. Empty on a profile that keeps none. */
|
|
168
|
+
sessions() {
|
|
169
|
+
return this.#sessionDir ? CodeSessionIndex.list(this.#sessionDir) : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The other `akan code` sessions running in this workspace right now. */
|
|
173
|
+
peers() {
|
|
174
|
+
return this.#mailbox?.peers() ?? [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Hands a message to another session, which receives it as a prompt of its own.
|
|
179
|
+
*
|
|
180
|
+
* It is a prompt rather than a note on its screen because the point of reaching another session is to set
|
|
181
|
+
* it working — and it goes in as a follow-up, so a peer in the middle of a turn finishes that turn first.
|
|
182
|
+
*/
|
|
183
|
+
send(target: string, text: string) {
|
|
184
|
+
const mailbox = this.#mailbox;
|
|
185
|
+
if (!mailbox) throw new Error("This profile keeps no shared mailbox, so it has no peers.");
|
|
186
|
+
const peer = mailbox.find(target);
|
|
187
|
+
if (!peer) throw new Error(`No live session matches "${target}". Ask for the list first.`);
|
|
188
|
+
mailbox.send(peer.id, text);
|
|
189
|
+
return peer;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#openMail(dir: string, cwd: string) {
|
|
193
|
+
const session = this.#session;
|
|
194
|
+
if (!session) return;
|
|
195
|
+
this.#mailbox = new CodeMailbox(dir, session.sessionId, session.sessionName ?? session.sessionId.slice(0, 8));
|
|
196
|
+
this.#mailbox.open(cwd, (mail) => {
|
|
197
|
+
const from = `${mail.fromName} (${mail.from.slice(0, 8)})`;
|
|
198
|
+
this.#emit({ type: "notice", level: "info", message: `message from ${from}` });
|
|
199
|
+
void this.prompt(`Message from another session — ${from}:\n${mail.text}`).catch((error: unknown) =>
|
|
200
|
+
this.#emit({ type: "notice", level: "error", message: String(error) }),
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get sessionId() {
|
|
206
|
+
return this.#session?.sessionId ?? "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get isStreaming() {
|
|
210
|
+
return this.#session?.isStreaming ?? false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Total tokens this session has spent, which is what a sub-agent budget is drawn down against. */
|
|
214
|
+
get tokensUsed() {
|
|
215
|
+
return this.#session?.getSessionStats().tokens.total ?? 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
get info(): CodeAgentSessionInfo {
|
|
219
|
+
const session = this.#require();
|
|
220
|
+
const model = session.model;
|
|
221
|
+
return {
|
|
222
|
+
sessionId: session.sessionId,
|
|
223
|
+
cwd: this.#profile.paths.root,
|
|
224
|
+
profile: this.#profile.name,
|
|
225
|
+
model: model ? { provider: model.provider, id: model.id, name: model.name } : undefined,
|
|
226
|
+
tools: session.getActiveToolNames(),
|
|
227
|
+
contextTokens: model?.contextWindow,
|
|
228
|
+
// A model that does no reasoning has no level to report, and `off` is a level — one of them is a
|
|
229
|
+
// capability and the other is a setting, so they cannot share a spelling.
|
|
230
|
+
effort: session.supportsThinking() ? session.thinkingLevel : undefined,
|
|
231
|
+
name: session.sessionName,
|
|
232
|
+
interaction: this.#profile.interaction,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Emits the opening `session` frame through the same counter as everything else.
|
|
238
|
+
*
|
|
239
|
+
* A frame minted outside it would carry `seq: 0`, which every client drops — their watermark starts there.
|
|
240
|
+
*/
|
|
241
|
+
announce() {
|
|
242
|
+
this.#emit({ type: "session", info: this.info });
|
|
243
|
+
this.#restore();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Replays a resumed session's conversation onto the wire.
|
|
248
|
+
*
|
|
249
|
+
* The model gets its context back from the session file; a host gets nothing, because the wire only carries
|
|
250
|
+
* what happens live. Without this a resumed session is a blank screen in front of an agent that remembers
|
|
251
|
+
* everything — which reads as a resume that failed.
|
|
252
|
+
*
|
|
253
|
+
* Prose only: the tool traffic of a past turn is in the file, but replaying it would redraw calls nobody is
|
|
254
|
+
* waiting on, against a working tree that has moved since.
|
|
255
|
+
*/
|
|
256
|
+
#restore() {
|
|
257
|
+
const session = this.#session;
|
|
258
|
+
if (!session) return;
|
|
259
|
+
const turnId = `restored:${session.sessionId}`;
|
|
260
|
+
for (const message of session.messages) {
|
|
261
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
262
|
+
const text = CodeAgent.#textOf(message.content);
|
|
263
|
+
if (text) this.#emit({ type: "message", turnId, role: message.role, text });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
static #textOf(content: unknown) {
|
|
268
|
+
if (typeof content === "string") return content.trim();
|
|
269
|
+
if (!Array.isArray(content)) return "";
|
|
270
|
+
return content
|
|
271
|
+
.map((part) => (typeof part === "object" && part && "text" in part ? String(part.text) : ""))
|
|
272
|
+
.join("")
|
|
273
|
+
.trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
on(listener: (event: CodeAgentEvent) => void) {
|
|
277
|
+
this.#listeners.add(listener);
|
|
278
|
+
return () => this.#listeners.delete(listener);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** One turn's worth of frames is kept for reconnection; a client further behind reloads the transcript. */
|
|
282
|
+
static readonly replayLimit = 2_000;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Current state, and the frames after `sinceSeq` when a reconnecting host asks for them.
|
|
286
|
+
*
|
|
287
|
+
* `replayFrom` says how far back the buffer reaches. A client behind it cannot be caught up frame by frame
|
|
288
|
+
* and has to reload — saying so is better than silently handing it a gap.
|
|
289
|
+
*/
|
|
290
|
+
async state({ sinceSeq }: { sinceSeq?: number } = {}): Promise<CodeAgentState> {
|
|
291
|
+
const base = { info: this.info, streaming: this.isStreaming, replayFrom: this.#replayFrom };
|
|
292
|
+
if (sinceSeq === undefined) return base;
|
|
293
|
+
return { ...base, frames: this.#replay.filter((event) => event.seq > sinceSeq) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async prompt(message: string, images?: CodeAgentImage[]) {
|
|
297
|
+
const session = this.#require();
|
|
298
|
+
// Named from the first thing asked of it, not from the answer: the name is derived, so waiting for the
|
|
299
|
+
// turn to end would only mean the session is nameless for exactly as long as it is interesting to watch.
|
|
300
|
+
if (!session.sessionName && message.trim()) this.setName(codeAgentSessionName(message));
|
|
301
|
+
const attachments = await CodeAgent.#attachments(images);
|
|
302
|
+
const options = attachments.length ? { images: attachments } : {};
|
|
303
|
+
if (session.isStreaming) return await session.prompt(message, { ...options, streamingBehavior: "followUp" });
|
|
304
|
+
await session.prompt(message, { ...options, source: "extension" });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async abort() {
|
|
308
|
+
// The engine finalizes an interrupted message as an ordinary stop, so the only party that knows this was
|
|
309
|
+
// an abort is the one that asked for it.
|
|
310
|
+
this.#mapper.noteOutcome("aborted");
|
|
311
|
+
this.#asks.clear();
|
|
312
|
+
await this.#require().abort();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Answering is not prompting. Routing an answer through `prompt` would open a turn while the question slot is
|
|
317
|
+
* still filled, leaving a card on screen that still looks clickable — the library does not stop that.
|
|
318
|
+
*/
|
|
319
|
+
async answer(questionId: string, answer: CodeAgentAnswer) {
|
|
320
|
+
const question = this.#asks.questionOf(questionId);
|
|
321
|
+
const rendered = question ? codeAgentRenderAnswer(question, answer) : (answer.text ?? "");
|
|
322
|
+
if (this.#asks.answer(questionId, rendered)) {
|
|
323
|
+
this.#emit({ type: "question_resolved", questionId, answer, rendered });
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
if (this.#profile.interaction.question !== "suspend") return false;
|
|
327
|
+
this.#emit({ type: "question_resolved", questionId, answer, rendered });
|
|
328
|
+
// The answer has to reach the model as prose: compaction and the next turn read message content only, so
|
|
329
|
+
// an answer that exists solely as structure is one the agent will not remember being given.
|
|
330
|
+
await this.prompt(`The user answered: ${rendered}\nContinue the task.`);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async approve(approvalId: string, approved: boolean) {
|
|
335
|
+
if (this.#asks.resolveApproval(approvalId, approved)) {
|
|
336
|
+
this.#emit({ type: "approval_resolved", approvalId, approved });
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
if (this.#profile.interaction.approval !== "suspend") return false;
|
|
340
|
+
this.#emit({ type: "approval_resolved", approvalId, approved });
|
|
341
|
+
if (approved) await this.prompt("The user approved the pending action. Retry it and continue.");
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async compact(instructions?: string) {
|
|
346
|
+
await this.#require().compact(instructions);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Renames the session and says so on the wire, because a host draws the name from the session frame. */
|
|
350
|
+
setName(name: string) {
|
|
351
|
+
const clean = name.trim();
|
|
352
|
+
if (!clean) return;
|
|
353
|
+
this.#require().setSessionName(clean);
|
|
354
|
+
this.#mailbox?.rename(clean, this.#profile.paths.root);
|
|
355
|
+
this.#emit({ type: "session", info: this.info });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Copies this session under a new id, so the conversation can be continued two ways.
|
|
360
|
+
*
|
|
361
|
+
* The copy is taken off disk rather than out of memory: the file is what a resume replays, so forking it is
|
|
362
|
+
* the only version of "the same conversation" that both agents will actually agree on.
|
|
363
|
+
*/
|
|
364
|
+
fork(name?: string) {
|
|
365
|
+
if (!this.#sessionDir) throw new Error("This profile keeps no sessions on disk, so there is nothing to fork");
|
|
366
|
+
return CodeSessionFork.fork(this.#sessionDir, this.sessionId, name);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The reasoning levels this model offers, clamped by the engine to what the provider actually serves. */
|
|
370
|
+
efforts(): CodeAgentEffort[] {
|
|
371
|
+
const session = this.#session;
|
|
372
|
+
if (!session?.supportsThinking()) return [];
|
|
373
|
+
return session.getAvailableThinkingLevels();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
setEffort(effort: CodeAgentEffort) {
|
|
377
|
+
const session = this.#require();
|
|
378
|
+
if (!session.supportsThinking()) throw new Error(`${session.model?.id ?? "this model"} does no reasoning`);
|
|
379
|
+
// The engine clamps rather than refuses, so the frame that follows is the level that took, not the one asked.
|
|
380
|
+
session.setThinkingLevel(effort);
|
|
381
|
+
this.#emit({ type: "session", info: this.info });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async setModel(ref: CodeAgentModelRef) {
|
|
385
|
+
const session = this.#require();
|
|
386
|
+
const model = await akanCodeModel(session.modelRegistry, ref);
|
|
387
|
+
if (!model) throw new Error(`Unknown model: ${ref.provider}/${ref.id}`);
|
|
388
|
+
await session.setModel(model);
|
|
389
|
+
this.#emit({ type: "session", info: this.info });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async waitForIdle() {
|
|
393
|
+
await this.#require().waitForIdle();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
dispose() {
|
|
397
|
+
this.#mailbox?.close();
|
|
398
|
+
if (this.#disposed) return;
|
|
399
|
+
this.#disposed = true;
|
|
400
|
+
this.#asks.clear();
|
|
401
|
+
this.#listeners.clear();
|
|
402
|
+
this.#disposePlugins?.();
|
|
403
|
+
this.#session?.dispose();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async #attach(session: AgentSession, mode: CodeAgentHostMode) {
|
|
407
|
+
this.#session = session;
|
|
408
|
+
session.subscribe((event) => {
|
|
409
|
+
for (const body of this.#mapper.map(event)) this.#emit(body);
|
|
410
|
+
this.#emitContextUsage(event.type);
|
|
411
|
+
});
|
|
412
|
+
await session.bindExtensions({
|
|
413
|
+
mode,
|
|
414
|
+
uiContext: this.#ui().context(),
|
|
415
|
+
abortHandler: () => void session.abort(),
|
|
416
|
+
onError: (error: unknown) => this.#emit({ type: "error", message: String(error), fatal: false }),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A path is read here rather than by the host, so a browser and a terminal hand the core the same thing. */
|
|
421
|
+
static async #attachments(images: CodeAgentImage[] | undefined) {
|
|
422
|
+
if (!images?.length) return [];
|
|
423
|
+
return await Promise.all(
|
|
424
|
+
images.map(async (image) => {
|
|
425
|
+
if ("data" in image) return { type: "image" as const, data: image.data, mimeType: image.mime };
|
|
426
|
+
const file = Bun.file(image.path);
|
|
427
|
+
return {
|
|
428
|
+
type: "image" as const,
|
|
429
|
+
data: Buffer.from(await file.arrayBuffer()).toString("base64"),
|
|
430
|
+
mimeType: file.type || "image/png",
|
|
431
|
+
};
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Reports how full the window is, once per turn and after a compaction.
|
|
438
|
+
*
|
|
439
|
+
* Per turn rather than per delta: the number moves only when a provider response lands, and a frame per
|
|
440
|
+
* token delta would be thousands of frames saying the same thing. `tokens` is null right after a compaction
|
|
441
|
+
* and before the next response — that is "unknown", not zero, so nothing is emitted for it.
|
|
442
|
+
*/
|
|
443
|
+
#emitContextUsage(eventType: string) {
|
|
444
|
+
if (eventType !== "agent_end" && eventType !== "compaction_end") return;
|
|
445
|
+
const usage = this.#session?.getContextUsage();
|
|
446
|
+
// `== null`, not `=== null`: the field is typed `number | null`, and a strict null check sails straight
|
|
447
|
+
// past an `undefined` the way the mirror of this bug does — which would emit `used: undefined`.
|
|
448
|
+
if (usage?.tokens == null) return;
|
|
449
|
+
if (usage.tokens === this.#lastContextTokens) return;
|
|
450
|
+
this.#lastContextTokens = usage.tokens;
|
|
451
|
+
this.#emit({ type: "context", used: usage.tokens, max: usage.contextWindow || undefined });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#require() {
|
|
455
|
+
if (!this.#session) throw new Error("CodeAgent is not started");
|
|
456
|
+
return this.#session;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#emit(body: CodeAgentEventBody) {
|
|
460
|
+
this.#seq += 1;
|
|
461
|
+
const event = { ...body, seq: this.#seq } as CodeAgentEvent;
|
|
462
|
+
this.#remember(event);
|
|
463
|
+
for (const listener of this.#listeners) listener(event);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
#remember(event: CodeAgentEvent) {
|
|
467
|
+
if (event.type === "idle") {
|
|
468
|
+
this.#replay.length = 0;
|
|
469
|
+
this.#replayFrom = event.seq;
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.#replay.push(event);
|
|
473
|
+
while (this.#replay.length > CodeAgent.replayLimit) {
|
|
474
|
+
const dropped = this.#replay.shift();
|
|
475
|
+
this.#replayFrom = dropped?.seq ?? this.#replayFrom;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#gateExtension(): InlineExtension {
|
|
480
|
+
return {
|
|
481
|
+
name: "akan-profile-gate",
|
|
482
|
+
factory: (pi: ExtensionAPI) => {
|
|
483
|
+
pi.on("tool_call", async (event) => {
|
|
484
|
+
const verdict = this.#gate.verdict(event.toolName, event.input);
|
|
485
|
+
if (verdict.block) return this.#refuse(event.toolCallId, event.toolName, event.input, verdict.block);
|
|
486
|
+
if (!verdict.approval) return undefined;
|
|
487
|
+
const approved = await this.#requestApproval(event.toolCallId, event.toolName, event.input, verdict.approval);
|
|
488
|
+
if (approved) return undefined;
|
|
489
|
+
return this.#refuse(event.toolCallId, event.toolName, event.input, "The user declined this action.");
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#refuse(toolCallId: string, toolName: string, args: unknown, reason: string) {
|
|
496
|
+
for (const body of this.#mapper.markBlocked(toolCallId, toolName, args, reason)) this.#emit(body);
|
|
497
|
+
return { block: true, reason };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async #requestApproval(toolCallId: string, name: string, args: unknown, summary: string) {
|
|
501
|
+
// Nobody to ask means nobody to refuse: a pod would otherwise deny every write it was created to make.
|
|
502
|
+
if (!this.#profile.ui.canPrompt) return true;
|
|
503
|
+
const approvalId = this.#asks.nextId("a");
|
|
504
|
+
this.#emit({
|
|
505
|
+
type: "approval",
|
|
506
|
+
request: {
|
|
507
|
+
approvalId,
|
|
508
|
+
toolCallId,
|
|
509
|
+
name,
|
|
510
|
+
summary: codeAgentClip(summary, codeAgentLabelChars),
|
|
511
|
+
policy: this.#profile.approval,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
if (this.#profile.interaction.approval === "suspend") {
|
|
515
|
+
this.#mapper.noteOutcome("awaiting");
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
return await this.#asks.openApproval(approvalId);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#ui() {
|
|
522
|
+
return new CodeAgentUi({
|
|
523
|
+
ask: async (prompt, kind, choices) => {
|
|
524
|
+
if (!this.#profile.ui.canPrompt) return undefined;
|
|
525
|
+
const questionId = this.#asks.nextId("q");
|
|
526
|
+
const question: CodeAgentQuestion = {
|
|
527
|
+
questionId,
|
|
528
|
+
prompt,
|
|
529
|
+
kind,
|
|
530
|
+
...(choices ? { options: choices.map((label) => ({ key: label, label })) } : {}),
|
|
531
|
+
freeText: kind === "text",
|
|
532
|
+
};
|
|
533
|
+
this.#emit({ type: "question", question });
|
|
534
|
+
if (this.#profile.interaction.question === "suspend") {
|
|
535
|
+
this.#mapper.noteOutcome("awaiting");
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
return (await this.#asks.openQuestion(question)) || undefined;
|
|
539
|
+
},
|
|
540
|
+
notify: (level, message) => this.#emit({ type: "notice", level, message }),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { CodeAgentQuestion } from "akanjs/common";
|
|
2
|
+
|
|
3
|
+
interface Deferred<T> {
|
|
4
|
+
resolve: (value: T) => void;
|
|
5
|
+
promise: Promise<T>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Holds what the agent is waiting on a person for.
|
|
10
|
+
*
|
|
11
|
+
* A question is a **single slot**: asking one ends the turn, so a second cannot exist. An approval is a
|
|
12
|
+
* **queue**: "may I write this file" naturally arrives twice in a row while a turn is still running, and a
|
|
13
|
+
* single slot would drop the second silently.
|
|
14
|
+
*
|
|
15
|
+
* Idempotency is by id. Two browser tabs, or answering after a refresh, both produce a second answer for an id
|
|
16
|
+
* that is already resolved — which must be ignored rather than applied to whatever is pending now.
|
|
17
|
+
*/
|
|
18
|
+
export class CodeAgentAsks {
|
|
19
|
+
#question: { question: CodeAgentQuestion; deferred: Deferred<string> } | undefined;
|
|
20
|
+
readonly #approvals = new Map<string, Deferred<boolean>>();
|
|
21
|
+
#nextId = 0;
|
|
22
|
+
|
|
23
|
+
get pendingQuestionId() {
|
|
24
|
+
return this.#question?.question.questionId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The open question, so an answer can be rendered against the options it was asked with. */
|
|
28
|
+
questionOf(questionId: string) {
|
|
29
|
+
return this.#question?.question.questionId === questionId ? this.#question.question : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get hasPendingApproval() {
|
|
33
|
+
return this.#approvals.size > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
nextId(prefix: string) {
|
|
37
|
+
this.#nextId += 1;
|
|
38
|
+
return `${prefix}${this.#nextId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
openQuestion(question: CodeAgentQuestion) {
|
|
42
|
+
// A question opened while one is already pending replaces it: the old turn is gone, so nobody is left to
|
|
43
|
+
// receive the old answer, and leaving it would strand the caller forever.
|
|
44
|
+
this.#question?.deferred.resolve("");
|
|
45
|
+
const deferred = CodeAgentAsks.#defer<string>();
|
|
46
|
+
this.#question = { question, deferred };
|
|
47
|
+
return deferred.promise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
answer(id: string, text: string) {
|
|
51
|
+
if (this.#question?.question.questionId !== id) return false;
|
|
52
|
+
const { deferred } = this.#question;
|
|
53
|
+
this.#question = undefined;
|
|
54
|
+
deferred.resolve(text);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
openApproval(id: string) {
|
|
59
|
+
const deferred = CodeAgentAsks.#defer<boolean>();
|
|
60
|
+
this.#approvals.set(id, deferred);
|
|
61
|
+
return deferred.promise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resolveApproval(id: string, approved: boolean) {
|
|
65
|
+
const deferred = this.#approvals.get(id);
|
|
66
|
+
if (!deferred) return false;
|
|
67
|
+
this.#approvals.delete(id);
|
|
68
|
+
deferred.resolve(approved);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every pending ask dies with its turn — an approval outlives nothing, and a question is reopened on resume. */
|
|
73
|
+
clear() {
|
|
74
|
+
this.#question?.deferred.resolve("");
|
|
75
|
+
this.#question = undefined;
|
|
76
|
+
for (const deferred of this.#approvals.values()) deferred.resolve(false);
|
|
77
|
+
this.#approvals.clear();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static #defer<T>(): Deferred<T> {
|
|
81
|
+
let resolve: (value: T) => void = () => {};
|
|
82
|
+
const promise = new Promise<T>((r) => {
|
|
83
|
+
resolve = r;
|
|
84
|
+
});
|
|
85
|
+
return { resolve, promise };
|
|
86
|
+
}
|
|
87
|
+
}
|