hoomanjs 1.40.2 → 1.41.0
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 +50 -3
- package/dist/acp/acp-agent.d.ts +16 -3
- package/dist/acp/acp-agent.js +371 -172
- package/dist/acp/acp-agent.js.map +1 -1
- package/dist/acp/commands.d.ts +6 -3
- package/dist/acp/commands.js +6 -24
- package/dist/acp/commands.js.map +1 -1
- package/dist/acp/meta/vscode.d.ts +10 -0
- package/dist/acp/meta/vscode.js +16 -0
- package/dist/acp/meta/vscode.js.map +1 -0
- package/dist/acp/questions.d.ts +18 -0
- package/dist/acp/questions.js +80 -0
- package/dist/acp/questions.js.map +1 -0
- package/dist/acp/session-config.d.ts +3 -1
- package/dist/acp/session-config.js +20 -8
- package/dist/acp/session-config.js.map +1 -1
- package/dist/acp/sessions/replay.js +117 -1
- package/dist/acp/sessions/replay.js.map +1 -1
- package/dist/acp/sessions/store.d.ts +55 -16
- package/dist/acp/sessions/store.js +174 -71
- package/dist/acp/sessions/store.js.map +1 -1
- package/dist/acp/utils/tool-kind.js +1 -0
- package/dist/acp/utils/tool-kind.js.map +1 -1
- package/dist/acp/utils/tool-result-content.d.ts +1 -0
- package/dist/acp/utils/tool-result-content.js +28 -1
- package/dist/acp/utils/tool-result-content.js.map +1 -1
- package/dist/chat/app.d.ts +3 -1
- package/dist/chat/app.js +65 -17
- package/dist/chat/app.js.map +1 -1
- package/dist/chat/components/BottomChrome.d.ts +12 -3
- package/dist/chat/components/BottomChrome.js +2 -2
- package/dist/chat/components/BottomChrome.js.map +1 -1
- package/dist/chat/components/ChromePicker.d.ts +5 -1
- package/dist/chat/components/ChromePicker.js +6 -2
- package/dist/chat/components/ChromePicker.js.map +1 -1
- package/dist/chat/components/QuestionPrompt.d.ts +8 -0
- package/dist/chat/components/QuestionPrompt.js +41 -0
- package/dist/chat/components/QuestionPrompt.js.map +1 -0
- package/dist/chat/components/StatusBar.d.ts +8 -3
- package/dist/chat/components/StatusBar.js +55 -26
- package/dist/chat/components/StatusBar.js.map +1 -1
- package/dist/chat/index.d.ts +2 -0
- package/dist/chat/index.js +1 -1
- package/dist/chat/index.js.map +1 -1
- package/dist/chat/questions.d.ts +24 -0
- package/dist/chat/questions.js +71 -0
- package/dist/chat/questions.js.map +1 -0
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/agent/index.d.ts +3 -0
- package/dist/core/agent/index.js +11 -5
- package/dist/core/agent/index.js.map +1 -1
- package/dist/core/agent/session-title-plugin.d.ts +27 -0
- package/dist/core/agent/session-title-plugin.js +77 -0
- package/dist/core/agent/session-title-plugin.js.map +1 -0
- package/dist/core/config.d.ts +30 -1
- package/dist/core/config.js +2 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/index.d.ts +12 -0
- package/dist/core/index.js +7 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/models/azure.js +5 -1
- package/dist/core/models/azure.js.map +1 -1
- package/dist/core/models/groq.js +5 -1
- package/dist/core/models/groq.js.map +1 -1
- package/dist/core/models/moonshot.js +5 -1
- package/dist/core/models/moonshot.js.map +1 -1
- package/dist/core/models/openai.js +6 -1
- package/dist/core/models/openai.js.map +1 -1
- package/dist/core/models/openrouter.js +5 -1
- package/dist/core/models/openrouter.js.map +1 -1
- package/dist/core/models/types.d.ts +44 -0
- package/dist/core/models/types.js +15 -0
- package/dist/core/models/types.js.map +1 -1
- package/dist/core/models/usage.d.ts +10 -0
- package/dist/core/models/usage.js +45 -0
- package/dist/core/models/usage.js.map +1 -0
- package/dist/core/models/xai.js +5 -1
- package/dist/core/models/xai.js.map +1 -1
- package/dist/core/modes/definitions.js +2 -0
- package/dist/core/modes/definitions.js.map +1 -1
- package/dist/core/prompts/static/ask-user.md +7 -0
- package/dist/core/prompts/static/filesystem.md +1 -1
- package/dist/core/prompts/system.js +5 -0
- package/dist/core/prompts/system.js.map +1 -1
- package/dist/core/sessions/generate-title.d.ts +10 -0
- package/dist/core/sessions/generate-title.js +113 -0
- package/dist/core/sessions/generate-title.js.map +1 -0
- package/dist/core/sessions/list-cli-sessions.js +13 -1
- package/dist/core/sessions/list-cli-sessions.js.map +1 -1
- package/dist/core/skills/built-in/hooman-config/providers.md +17 -0
- package/dist/core/skills/built-in/hooman-skills/SKILL.md +1 -1
- package/dist/core/state/session-title.d.ts +17 -0
- package/dist/core/state/session-title.js +15 -0
- package/dist/core/state/session-title.js.map +1 -0
- package/dist/core/state/tool-approvals.js +3 -0
- package/dist/core/state/tool-approvals.js.map +1 -1
- package/dist/core/tools/ask-user.d.ts +38 -0
- package/dist/core/tools/ask-user.js +62 -0
- package/dist/core/tools/ask-user.js.map +1 -0
- package/dist/core/tools/filesystem.d.ts +26 -1
- package/dist/core/tools/filesystem.js +44 -23
- package/dist/core/tools/filesystem.js.map +1 -1
- package/dist/core/tools/index.d.ts +1 -0
- package/dist/core/tools/index.js +1 -0
- package/dist/core/tools/index.js.map +1 -1
- package/dist/core/tools/shell.d.ts +41 -0
- package/dist/core/tools/shell.js +8 -1
- package/dist/core/tools/shell.js.map +1 -1
- package/dist/core/utils/billing.d.ts +49 -0
- package/dist/core/utils/billing.js +295 -0
- package/dist/core/utils/billing.js.map +1 -0
- package/dist/core/utils/edit-replace.d.ts +6 -0
- package/dist/core/utils/edit-replace.js +88 -6
- package/dist/core/utils/edit-replace.js.map +1 -1
- package/dist/core/utils/paths.d.ts +1 -0
- package/dist/core/utils/paths.js +1 -0
- package/dist/core/utils/paths.js.map +1 -1
- package/dist/core/{models → utils}/reasoning-effort.d.ts +9 -0
- package/dist/core/utils/reasoning-effort.js.map +1 -0
- package/dist/exec/questions.d.ts +9 -0
- package/dist/exec/questions.js +50 -0
- package/dist/exec/questions.js.map +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/dist/core/models/reasoning-effort.js.map +0 -1
- package/dist/core/tools/terminal-backend.d.ts +0 -41
- package/dist/core/tools/terminal-backend.js +0 -9
- package/dist/core/tools/terminal-backend.js.map +0 -1
- package/dist/core/tools/text-fs-backend.d.ts +0 -25
- package/dist/core/tools/text-fs-backend.js +0 -9
- package/dist/core/tools/text-fs-backend.js.map +0 -1
- /package/dist/core/{models → utils}/reasoning-effort.js +0 -0
package/dist/acp/acp-agent.js
CHANGED
|
@@ -3,20 +3,22 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { Readable, Writable } from "node:stream";
|
|
4
4
|
import { stdin, stdout } from "node:process";
|
|
5
5
|
import { agent as acpAgent, methods, ndJsonStream, PROTOCOL_VERSION, RequestError, } from "@agentclientprotocol/sdk";
|
|
6
|
-
import { isModelStreamEvent,
|
|
6
|
+
import { isModelStreamEvent, } from "@strands-agents/sdk";
|
|
7
7
|
import { bootstrap } from "../core/index.js";
|
|
8
8
|
import { applySessionMode } from "../core/agent/sync-tool-registry-mode.js";
|
|
9
9
|
import { MODE_DEFINITIONS } from "../core/modes/definitions.js";
|
|
10
10
|
import { DEFAULT_SESSION_MODE, isKnownSessionMode, } from "../core/modes/schema.js";
|
|
11
11
|
import { getModeState, setSessionMode } from "../core/state/session-mode.js";
|
|
12
12
|
import { isYoloEnabled, setYoloEnabled } from "../core/state/yolo.js";
|
|
13
|
+
import { ChatTurnSteeringController, createChatTurnSteeringIntervention, } from "../core/agent/turn-steering.js";
|
|
13
14
|
import { getAgentConversationManager, getAgentSessionManager, } from "../core/agent/index.js";
|
|
14
15
|
import { readBundledPrompt } from "../core/prompts/bundled.js";
|
|
15
|
-
import { formatModeNames } from "../core/modes/definitions.js";
|
|
16
16
|
import { modelProviders } from "../core/models/index.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
17
|
+
import { toAdditiveUsage } from "../core/models/usage.js";
|
|
18
|
+
import { computeUsageCostUsd, contextTokensFromUsage, resolveLlmBilling, } from "../core/utils/billing.js";
|
|
19
|
+
import { buildSessionConfigOptions, currentModelName, CONFIG_ID_EFFORT, CONFIG_ID_MODE, CONFIG_ID_MODEL, MODE_VALUE_YOLO, } from "./session-config.js";
|
|
20
|
+
import { activeProviderName, parseReasoningEffortArg, withReasoningEffort, } from "../core/utils/reasoning-effort.js";
|
|
21
|
+
import { ACP_SLASH_COMMANDS, parseAcpSlashCommand, } from "./commands.js";
|
|
20
22
|
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL, } from "../core/state/tool-approvals.js";
|
|
21
23
|
import { runWithCwd } from "../core/utils/cwd-context.js";
|
|
22
24
|
import { runWithAgentMemoryScope } from "../core/memory/index.js";
|
|
@@ -28,15 +30,20 @@ import { toolCallLocationsFromInput } from "./utils/tool-locations.js";
|
|
|
28
30
|
import { takeFileToolDisplay } from "../core/state/file-tool-display.js";
|
|
29
31
|
import { getTodoViewState } from "../core/state/todos.js";
|
|
30
32
|
import { UPDATE_TODOS_TOOL_NAME } from "../core/tools/todo.js";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
+
import { setAskUserBackend } from "../core/tools/ask-user.js";
|
|
34
|
+
import { setTextFsBackend } from "../core/tools/filesystem.js";
|
|
35
|
+
import { setTerminalBackend, } from "../core/tools/shell.js";
|
|
33
36
|
import { createAcpToolApprovalIntervention } from "./approvals.js";
|
|
37
|
+
import { createAcpAskUserBackend } from "./questions.js";
|
|
34
38
|
import { extractAcpClientUserId } from "./meta/user-id.js";
|
|
39
|
+
import { extractAcpVscodeFlag } from "./meta/vscode.js";
|
|
35
40
|
import { deriveSessionTitleFromEcho } from "./sessions/title.js";
|
|
36
41
|
import { acpPromptEchoText, acpPromptToInvokeArgs } from "./prompt-invoke.js";
|
|
37
42
|
import { normalizeAcpSessionMcpServers } from "./mcp-servers.js";
|
|
38
43
|
import { replayConversationHistory } from "./sessions/replay.js";
|
|
39
|
-
import {
|
|
44
|
+
import { compactSessionIndex, deleteSessionEntry, migrateLegacySessionStore, patchSessionEntry, readSessionEntry, readSessionIndex, touchSessionEntry, writeSessionEntry, } from "./sessions/store.js";
|
|
45
|
+
import { FlatFileStorage } from "../core/sessions/flat-file-storage.js";
|
|
46
|
+
import { sessionsPath } from "../core/utils/paths.js";
|
|
40
47
|
/** Max sessions returned per `session/list` page. */
|
|
41
48
|
const LIST_PAGE_SIZE = 40;
|
|
42
49
|
/** Fallback when a session record is missing (never mutate). */
|
|
@@ -83,6 +90,36 @@ function decodeCursor(cursor) {
|
|
|
83
90
|
message: "Invalid pagination cursor",
|
|
84
91
|
});
|
|
85
92
|
}
|
|
93
|
+
/** Zeroed `Usage` accumulator for a fresh session's cumulative billing meter. */
|
|
94
|
+
function createEmptyUsage() {
|
|
95
|
+
return { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Accumulate `source` into `target` in place, mirroring the CLI TUI's
|
|
99
|
+
* cumulative billing meter (`src/chat/app.tsx`): sum every request's token
|
|
100
|
+
* usage over the session, including cache reads/writes which recur (and are
|
|
101
|
+
* billed) on every request. `source` must already be in the additive shape
|
|
102
|
+
* (see `toAdditiveUsage`), where cache reads are not part of `inputTokens`.
|
|
103
|
+
*/
|
|
104
|
+
function accumulateUsage(target, source) {
|
|
105
|
+
target.inputTokens += source.inputTokens ?? 0;
|
|
106
|
+
target.outputTokens += source.outputTokens ?? 0;
|
|
107
|
+
if (source.cacheReadInputTokens !== undefined ||
|
|
108
|
+
target.cacheReadInputTokens !== undefined) {
|
|
109
|
+
target.cacheReadInputTokens =
|
|
110
|
+
(target.cacheReadInputTokens ?? 0) + (source.cacheReadInputTokens ?? 0);
|
|
111
|
+
}
|
|
112
|
+
if (source.cacheWriteInputTokens !== undefined ||
|
|
113
|
+
target.cacheWriteInputTokens !== undefined) {
|
|
114
|
+
target.cacheWriteInputTokens =
|
|
115
|
+
(target.cacheWriteInputTokens ?? 0) + (source.cacheWriteInputTokens ?? 0);
|
|
116
|
+
}
|
|
117
|
+
target.totalTokens =
|
|
118
|
+
target.inputTokens +
|
|
119
|
+
target.outputTokens +
|
|
120
|
+
(target.cacheReadInputTokens ?? 0) +
|
|
121
|
+
(target.cacheWriteInputTokens ?? 0);
|
|
122
|
+
}
|
|
86
123
|
function assertAbsolutePath(value, field) {
|
|
87
124
|
const trimmed = value?.trim() ?? "";
|
|
88
125
|
if (!trimmed) {
|
|
@@ -122,23 +159,26 @@ function isCancellationError(err, cancelSignals) {
|
|
|
122
159
|
function toAcpStopReason(reason) {
|
|
123
160
|
switch (reason) {
|
|
124
161
|
case "cancelled":
|
|
162
|
+
case "interrupt":
|
|
125
163
|
return "cancelled";
|
|
126
164
|
case "maxTokens":
|
|
127
165
|
case "modelContextWindowExceeded":
|
|
166
|
+
case "limitOutputTokens":
|
|
167
|
+
case "limitTotalTokens":
|
|
128
168
|
return "max_tokens";
|
|
169
|
+
case "limitTurns":
|
|
170
|
+
return "max_turn_requests";
|
|
129
171
|
case "contentFiltered":
|
|
130
172
|
case "guardrailIntervened":
|
|
131
173
|
return "refusal";
|
|
132
174
|
case "endTurn":
|
|
133
175
|
case "toolUse":
|
|
134
176
|
case "stopSequence":
|
|
177
|
+
case "pauseTurn":
|
|
135
178
|
default:
|
|
136
179
|
return "end_turn";
|
|
137
180
|
}
|
|
138
181
|
}
|
|
139
|
-
function serializeAgentMessages(agent) {
|
|
140
|
-
return agent.messages.map((m) => m.toJSON());
|
|
141
|
-
}
|
|
142
182
|
/** Normalize a persisted/requested mode to a known id, else the default. */
|
|
143
183
|
function resolveSessionMode(mode) {
|
|
144
184
|
return mode && isKnownSessionMode(mode) ? mode : DEFAULT_SESSION_MODE;
|
|
@@ -164,6 +204,10 @@ function buildSessionModeState(currentModeId) {
|
|
|
164
204
|
export class HoomanAcpAgent {
|
|
165
205
|
#identity;
|
|
166
206
|
#acpRoot = acpSessionsRootPath();
|
|
207
|
+
/** Strands snapshot storage root (`<project>/sessions`); message source of truth. */
|
|
208
|
+
#snapshotsRoot = sessionsPath();
|
|
209
|
+
/** Resolves once the legacy store migration + index compaction have run. */
|
|
210
|
+
#storeReady;
|
|
167
211
|
#sessions = new Map();
|
|
168
212
|
/** Connection-scoped outbound context, captured in {@link onConnect}. */
|
|
169
213
|
#client = null;
|
|
@@ -174,6 +218,9 @@ export class HoomanAcpAgent {
|
|
|
174
218
|
#clientTerminalCap = false;
|
|
175
219
|
constructor(identity) {
|
|
176
220
|
this.#identity = identity;
|
|
221
|
+
this.#storeReady = migrateLegacySessionStore(this.#acpRoot, this.#snapshotsRoot)
|
|
222
|
+
.then(() => compactSessionIndex(this.#acpRoot))
|
|
223
|
+
.catch(() => undefined);
|
|
177
224
|
}
|
|
178
225
|
/** Bind connection-scoped lifecycle: capture the client + dispose on close. */
|
|
179
226
|
onConnect(connection) {
|
|
@@ -202,7 +249,6 @@ export class HoomanAcpAgent {
|
|
|
202
249
|
loadSession: true,
|
|
203
250
|
promptCapabilities: {
|
|
204
251
|
image: true,
|
|
205
|
-
audio: true,
|
|
206
252
|
embeddedContext: true,
|
|
207
253
|
},
|
|
208
254
|
mcpCapabilities: {
|
|
@@ -212,17 +258,24 @@ export class HoomanAcpAgent {
|
|
|
212
258
|
sessionCapabilities: {
|
|
213
259
|
list: {},
|
|
214
260
|
delete: {},
|
|
261
|
+
resume: {},
|
|
262
|
+
close: {},
|
|
215
263
|
},
|
|
216
264
|
},
|
|
217
265
|
};
|
|
218
266
|
}
|
|
219
267
|
async listSessions(params) {
|
|
220
|
-
|
|
268
|
+
await this.#storeReady;
|
|
269
|
+
const entries = await readSessionIndex(this.#acpRoot);
|
|
221
270
|
const sessions = [];
|
|
222
|
-
for (const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
271
|
+
for (const entry of entries.values()) {
|
|
272
|
+
if (!params.cwd || entry.cwd === params.cwd) {
|
|
273
|
+
sessions.push({
|
|
274
|
+
sessionId: entry.sessionId,
|
|
275
|
+
cwd: entry.cwd,
|
|
276
|
+
title: entry.title ?? null,
|
|
277
|
+
updatedAt: entry.updatedAt,
|
|
278
|
+
});
|
|
226
279
|
}
|
|
227
280
|
}
|
|
228
281
|
sessions.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
|
|
@@ -233,70 +286,135 @@ export class HoomanAcpAgent {
|
|
|
233
286
|
return { sessions: page, nextCursor };
|
|
234
287
|
}
|
|
235
288
|
async deleteSession(params) {
|
|
289
|
+
await this.#storeReady;
|
|
236
290
|
const record = this.#sessions.get(params.sessionId);
|
|
237
291
|
if (record) {
|
|
238
292
|
this.#sessions.delete(params.sessionId);
|
|
239
293
|
record.turnAbort?.abort();
|
|
240
294
|
await record.mcpDisconnect();
|
|
241
295
|
}
|
|
242
|
-
await
|
|
296
|
+
await deleteSessionEntry(this.#acpRoot, params.sessionId);
|
|
297
|
+
// Also drop the Strands snapshot (the conversation history itself).
|
|
298
|
+
await new FlatFileStorage(this.#snapshotsRoot).deleteSession({
|
|
299
|
+
sessionId: params.sessionId,
|
|
300
|
+
});
|
|
243
301
|
}
|
|
244
302
|
async newSession(params) {
|
|
303
|
+
await this.#storeReady;
|
|
245
304
|
assertAbsolutePath(params.cwd, "cwd");
|
|
246
305
|
const sessionId = crypto.randomUUID();
|
|
247
306
|
const clientUserId = extractAcpClientUserId(params._meta) ?? null;
|
|
248
307
|
const mcpServers = normalizeAcpSessionMcpServers(params.mcpServers);
|
|
308
|
+
const vscode = extractAcpVscodeFlag(params._meta);
|
|
249
309
|
const mode = DEFAULT_SESSION_MODE;
|
|
250
310
|
const now = new Date().toISOString();
|
|
251
|
-
const
|
|
311
|
+
const entry = {
|
|
312
|
+
sessionId,
|
|
252
313
|
cwd: params.cwd,
|
|
253
314
|
createdAt: now,
|
|
254
315
|
updatedAt: now,
|
|
255
316
|
title: null,
|
|
256
317
|
userId: clientUserId,
|
|
257
318
|
mcpServers,
|
|
319
|
+
...(vscode ? { vscode } : {}),
|
|
258
320
|
sessionMode: mode,
|
|
259
321
|
};
|
|
260
|
-
await
|
|
261
|
-
const record = await this.#bootstrapSession(sessionId, params.cwd, clientUserId ?? sessionId, mcpServers, mode);
|
|
322
|
+
await writeSessionEntry(this.#acpRoot, entry);
|
|
323
|
+
const record = await this.#bootstrapSession(sessionId, params.cwd, clientUserId ?? sessionId, mcpServers, mode, undefined, vscode);
|
|
262
324
|
this.#sessions.set(sessionId, record);
|
|
263
325
|
await this.#advertiseCommands(this.#requireClient(), sessionId);
|
|
264
326
|
return {
|
|
265
327
|
sessionId,
|
|
266
328
|
modes: buildSessionModeState(mode),
|
|
267
|
-
configOptions: buildSessionConfigOptions(record.config, mode),
|
|
329
|
+
configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
|
|
268
330
|
};
|
|
269
331
|
}
|
|
270
332
|
async loadSession(params, client) {
|
|
333
|
+
const { record, mode } = await this.#reactivateSession({
|
|
334
|
+
sessionId: params.sessionId,
|
|
335
|
+
cwd: params.cwd,
|
|
336
|
+
mcpServers: params.mcpServers,
|
|
337
|
+
meta: params._meta,
|
|
338
|
+
}, client, { replayHistory: true });
|
|
339
|
+
return {
|
|
340
|
+
modes: buildSessionModeState(mode),
|
|
341
|
+
configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Handle `session/resume`: reactivate a persisted session's in-memory state
|
|
346
|
+
* (including its conversation history, so the turn continues correctly)
|
|
347
|
+
* without replaying that history to the client — the spec reserves the full
|
|
348
|
+
* transcript replay for `session/load`.
|
|
349
|
+
*/
|
|
350
|
+
async resumeSession(params, client) {
|
|
351
|
+
const { record, mode } = await this.#reactivateSession({
|
|
352
|
+
sessionId: params.sessionId,
|
|
353
|
+
cwd: params.cwd,
|
|
354
|
+
mcpServers: params.mcpServers ?? [],
|
|
355
|
+
meta: params._meta,
|
|
356
|
+
}, client, { replayHistory: false });
|
|
357
|
+
return {
|
|
358
|
+
modes: buildSessionModeState(mode),
|
|
359
|
+
configOptions: buildSessionConfigOptions(record.config, mode, isYoloEnabled(record.agent)),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Handle `session/close`: cancel any in-flight turn and free the in-memory
|
|
364
|
+
* session state, without deleting the persisted session from disk (unlike
|
|
365
|
+
* `session/delete`).
|
|
366
|
+
*/
|
|
367
|
+
async closeSession(params) {
|
|
368
|
+
const record = this.#sessions.get(params.sessionId);
|
|
369
|
+
if (record) {
|
|
370
|
+
this.#sessions.delete(params.sessionId);
|
|
371
|
+
record.turnAbort?.abort();
|
|
372
|
+
await record.mcpDisconnect();
|
|
373
|
+
}
|
|
374
|
+
return {};
|
|
375
|
+
}
|
|
376
|
+
/** Shared setup for `session/load` and `session/resume`. */
|
|
377
|
+
async #reactivateSession(params, client, options) {
|
|
271
378
|
if (this.#sessions.has(params.sessionId)) {
|
|
272
379
|
throw RequestError.invalidParams({
|
|
273
380
|
sessionId: params.sessionId,
|
|
274
381
|
message: "Session is already active in this agent process.",
|
|
275
382
|
});
|
|
276
383
|
}
|
|
277
|
-
|
|
384
|
+
await this.#storeReady;
|
|
385
|
+
const existing = await readSessionEntry(this.#acpRoot, params.sessionId);
|
|
278
386
|
if (!existing) {
|
|
279
387
|
throw RequestError.resourceNotFound(`session:${params.sessionId}`);
|
|
280
388
|
}
|
|
281
389
|
assertAbsolutePath(params.cwd, "cwd");
|
|
282
|
-
const fromRequest = extractAcpClientUserId(params.
|
|
390
|
+
const fromRequest = extractAcpClientUserId(params.meta);
|
|
283
391
|
const clientUserId = fromRequest !== undefined ? fromRequest : (existing.userId ?? null);
|
|
284
392
|
const mcpServers = params.mcpServers.length > 0
|
|
285
393
|
? normalizeAcpSessionMcpServers(params.mcpServers)
|
|
286
394
|
: (existing.mcpServers ?? []);
|
|
395
|
+
const vscode = extractAcpVscodeFlag(params.meta) || existing.vscode === true;
|
|
287
396
|
const mode = resolveSessionMode(existing.sessionMode);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
record.
|
|
291
|
-
|
|
292
|
-
|
|
397
|
+
// Bootstrapping restores the Strands snapshot (messages + appState) during
|
|
398
|
+
// `agent.initialize()` — the snapshot is the conversation source of truth.
|
|
399
|
+
const record = await this.#bootstrapSession(params.sessionId, params.cwd, clientUserId ?? params.sessionId, mcpServers, mode, existing.model, vscode);
|
|
400
|
+
// The snapshot restore replaces appState wholesale, so re-apply the
|
|
401
|
+
// connection-scoped values that must win over persisted state: the
|
|
402
|
+
// client's user id, and the index-persisted yolo/mode settings.
|
|
403
|
+
record.agent.appState.set("userId", clientUserId ?? params.sessionId);
|
|
404
|
+
setYoloEnabled(record.agent, existing.yolo === true);
|
|
405
|
+
if (resolveSessionMode(getModeState(record.agent).mode) !== mode) {
|
|
406
|
+
setSessionMode(record.agent, mode);
|
|
407
|
+
applySessionMode(record.agent);
|
|
408
|
+
}
|
|
409
|
+
if (options.replayHistory) {
|
|
410
|
+
await replayConversationHistory(client, params.sessionId, record.agent.messages);
|
|
293
411
|
}
|
|
294
|
-
await replayConversationHistory(client, params.sessionId, record.agent.messages);
|
|
295
412
|
this.#sessions.set(params.sessionId, record);
|
|
296
|
-
await
|
|
413
|
+
await patchSessionEntry(this.#acpRoot, params.sessionId, {
|
|
297
414
|
cwd: params.cwd,
|
|
298
415
|
...(fromRequest !== undefined ? { userId: fromRequest || null } : {}),
|
|
299
416
|
mcpServers,
|
|
417
|
+
...(vscode ? { vscode } : {}),
|
|
300
418
|
sessionMode: mode,
|
|
301
419
|
});
|
|
302
420
|
await this.#advertiseCommands(client, params.sessionId);
|
|
@@ -304,10 +422,7 @@ export class HoomanAcpAgent {
|
|
|
304
422
|
if (getTodoViewState(record.agent).total > 0) {
|
|
305
423
|
await this.#sendPlanUpdate(client, params.sessionId, record);
|
|
306
424
|
}
|
|
307
|
-
return {
|
|
308
|
-
modes: buildSessionModeState(mode),
|
|
309
|
-
configOptions: buildSessionConfigOptions(record.config, mode),
|
|
310
|
-
};
|
|
425
|
+
return { record, mode };
|
|
311
426
|
}
|
|
312
427
|
/** Advertise the available slash commands for a freshly set-up session. */
|
|
313
428
|
#advertiseCommands(client, sessionId) {
|
|
@@ -320,7 +435,7 @@ export class HoomanAcpAgent {
|
|
|
320
435
|
* Handle `session/set_mode`: switch the active mode for a running session,
|
|
321
436
|
* re-applying the tool surface and persisting the choice.
|
|
322
437
|
*/
|
|
323
|
-
async setSessionMode(params) {
|
|
438
|
+
async setSessionMode(params, client) {
|
|
324
439
|
const rec = this.#sessions.get(params.sessionId);
|
|
325
440
|
if (!rec) {
|
|
326
441
|
throw RequestError.invalidParams({ sessionId: params.sessionId });
|
|
@@ -332,9 +447,9 @@ export class HoomanAcpAgent {
|
|
|
332
447
|
});
|
|
333
448
|
}
|
|
334
449
|
if (this.#transitionMode(rec, params.modeId)) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
450
|
+
// Keep the (superseding) config-options `mode` value in sync too, since
|
|
451
|
+
// Clients may read either surface (see Session Config Options spec).
|
|
452
|
+
await this.#syncCurrentMode(client, params.sessionId, rec);
|
|
338
453
|
}
|
|
339
454
|
return {};
|
|
340
455
|
}
|
|
@@ -342,7 +457,7 @@ export class HoomanAcpAgent {
|
|
|
342
457
|
* Handle `session/set_config_option`: apply a model or mode selection and
|
|
343
458
|
* return the complete, up-to-date configuration state (as the spec requires).
|
|
344
459
|
*/
|
|
345
|
-
async setSessionConfigOption(params) {
|
|
460
|
+
async setSessionConfigOption(params, client) {
|
|
346
461
|
const rec = this.#sessions.get(params.sessionId);
|
|
347
462
|
if (!rec) {
|
|
348
463
|
throw RequestError.invalidParams({ sessionId: params.sessionId });
|
|
@@ -355,21 +470,55 @@ export class HoomanAcpAgent {
|
|
|
355
470
|
}
|
|
356
471
|
const value = params.value;
|
|
357
472
|
if (params.configId === CONFIG_ID_MODE) {
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
473
|
+
if (value === MODE_VALUE_YOLO) {
|
|
474
|
+
// Yolo is agent mode with auto-approval on, not a distinct SessionMode.
|
|
475
|
+
if (this.#transitionMode(rec, "agent")) {
|
|
476
|
+
// Keep the legacy `modes` surface in sync too (see Session Config
|
|
477
|
+
// Options spec: "Agents SHOULD keep both in sync").
|
|
478
|
+
await this.#syncCurrentMode(client, params.sessionId, rec);
|
|
479
|
+
}
|
|
480
|
+
if (!isYoloEnabled(rec.agent)) {
|
|
481
|
+
setYoloEnabled(rec.agent, true);
|
|
482
|
+
await patchSessionEntry(this.#acpRoot, params.sessionId, {
|
|
483
|
+
yolo: true,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
363
486
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
487
|
+
else {
|
|
488
|
+
if (!isKnownSessionMode(value)) {
|
|
489
|
+
throw RequestError.invalidParams({
|
|
490
|
+
value,
|
|
491
|
+
message: `Unknown mode "${value}"`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
if (this.#transitionMode(rec, value)) {
|
|
495
|
+
// Keep the legacy `modes` surface in sync too (see Session Config
|
|
496
|
+
// Options spec: "Agents SHOULD keep both in sync").
|
|
497
|
+
await this.#syncCurrentMode(client, params.sessionId, rec);
|
|
498
|
+
}
|
|
499
|
+
// Selecting a plain mode always turns yolo back off.
|
|
500
|
+
if (isYoloEnabled(rec.agent)) {
|
|
501
|
+
setYoloEnabled(rec.agent, false);
|
|
502
|
+
await patchSessionEntry(this.#acpRoot, params.sessionId, {
|
|
503
|
+
yolo: false,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
368
506
|
}
|
|
369
507
|
}
|
|
370
508
|
else if (params.configId === CONFIG_ID_MODEL) {
|
|
371
509
|
await this.#applyModelChange(rec, value);
|
|
372
|
-
await
|
|
510
|
+
await patchSessionEntry(this.#acpRoot, params.sessionId, {
|
|
511
|
+
model: value,
|
|
512
|
+
});
|
|
513
|
+
// The conversation (and thus the context tokens in use) carries over to
|
|
514
|
+
// the new model, so rescale the client's context gauge against the new
|
|
515
|
+
// window immediately instead of leaving the old one up until the next
|
|
516
|
+
// turn ends. Skipped mid-turn: the rebuild (and billing re-resolution)
|
|
517
|
+
// is deferred to the turn boundary, and the running turn's own
|
|
518
|
+
// `usage_update` is still correctly priced against the old model.
|
|
519
|
+
if (!this.#isTurnActive(rec)) {
|
|
520
|
+
await this.#sendUsageUpdate(this.#requireClient(), params.sessionId, rec, undefined);
|
|
521
|
+
}
|
|
373
522
|
}
|
|
374
523
|
else if (params.configId === CONFIG_ID_EFFORT) {
|
|
375
524
|
const parsed = parseReasoningEffortArg(value);
|
|
@@ -388,7 +537,7 @@ export class HoomanAcpAgent {
|
|
|
388
537
|
});
|
|
389
538
|
}
|
|
390
539
|
return {
|
|
391
|
-
configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
|
|
540
|
+
configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode), isYoloEnabled(rec.agent)),
|
|
392
541
|
};
|
|
393
542
|
}
|
|
394
543
|
/** Whether a prompt turn is currently streaming for this session. */
|
|
@@ -522,6 +671,7 @@ export class HoomanAcpAgent {
|
|
|
522
671
|
const resolved = rec.config.llm;
|
|
523
672
|
const provider = await modelProviders[resolved.provider]();
|
|
524
673
|
rec.agent.model = provider.create(resolved.providerOptions, resolved.llmOptions);
|
|
674
|
+
rec.billing = await resolveLlmBilling(resolved.billing, resolved.llmOptions.model).catch(() => null);
|
|
525
675
|
}
|
|
526
676
|
catch (error) {
|
|
527
677
|
if (previous) {
|
|
@@ -541,7 +691,7 @@ export class HoomanAcpAgent {
|
|
|
541
691
|
* Apply settings changes deferred during a turn (mode tool-surface + model
|
|
542
692
|
* rebuild). Runs at the turn boundary before the next turn may start.
|
|
543
693
|
*/
|
|
544
|
-
async #flushPendingSettings(rec) {
|
|
694
|
+
async #flushPendingSettings(rec, client, sessionId) {
|
|
545
695
|
if (rec.pendingModeReapply) {
|
|
546
696
|
rec.pendingModeReapply = false;
|
|
547
697
|
applySessionMode(rec.agent);
|
|
@@ -560,6 +710,11 @@ export class HoomanAcpAgent {
|
|
|
560
710
|
if (persistEffort) {
|
|
561
711
|
this.#persistProviderEffort(rec, persistEffort.provider, persistEffort.effort);
|
|
562
712
|
}
|
|
713
|
+
// The rebuild re-resolved billing; rescale the client's context gauge
|
|
714
|
+
// against the new model's window (the turn's earlier `usage_update`
|
|
715
|
+
// carried the previous model's size). Best-effort: the turn result
|
|
716
|
+
// must not fail over a lost notification.
|
|
717
|
+
await this.#sendUsageUpdate(client, sessionId, rec, undefined).catch(() => undefined);
|
|
563
718
|
}
|
|
564
719
|
}
|
|
565
720
|
}
|
|
@@ -567,117 +722,14 @@ export class HoomanAcpAgent {
|
|
|
567
722
|
* Execute an inline (non-model) slash command and return the text reply to
|
|
568
723
|
* stream back as an `agent_message_chunk`. `/init` is handled by the caller.
|
|
569
724
|
*/
|
|
570
|
-
#runControlCommand(
|
|
725
|
+
#runControlCommand(rec, command) {
|
|
571
726
|
switch (command.name) {
|
|
572
|
-
case "mode":
|
|
573
|
-
return this.#commandSetMode(client, sessionId, rec, command.args);
|
|
574
|
-
case "model":
|
|
575
|
-
return this.#commandSetModel(client, sessionId, rec, command.args);
|
|
576
|
-
case "effort":
|
|
577
|
-
return this.#commandSetEffort(client, sessionId, rec, command.args);
|
|
578
|
-
case "yolo":
|
|
579
|
-
return this.#commandSetYolo(sessionId, rec, command.args);
|
|
580
727
|
case "compact":
|
|
581
728
|
return this.#commandCompact(rec);
|
|
582
729
|
default:
|
|
583
730
|
return Promise.resolve(`Unknown command "/${command.name}".`);
|
|
584
731
|
}
|
|
585
732
|
}
|
|
586
|
-
async #commandSetMode(client, sessionId, rec, args) {
|
|
587
|
-
const current = resolveSessionMode(getModeState(rec.agent).mode);
|
|
588
|
-
const arg = args.trim().toLowerCase();
|
|
589
|
-
if (!arg) {
|
|
590
|
-
return `Usage: /mode <${formatModeNames()}>. Current mode: "${current}".`;
|
|
591
|
-
}
|
|
592
|
-
if (!isKnownSessionMode(arg)) {
|
|
593
|
-
return `Unknown mode "${arg}". Use ${formatModeNames()}.`;
|
|
594
|
-
}
|
|
595
|
-
if (!this.#transitionMode(rec, arg)) {
|
|
596
|
-
return `Already in "${arg}" mode.`;
|
|
597
|
-
}
|
|
598
|
-
await this.#syncCurrentMode(client, sessionId, rec);
|
|
599
|
-
return `Switched session mode to "${arg}".`;
|
|
600
|
-
}
|
|
601
|
-
async #commandSetModel(client, sessionId, rec, args) {
|
|
602
|
-
const arg = args.trim();
|
|
603
|
-
if (!arg) {
|
|
604
|
-
return this.#listModelsText(rec);
|
|
605
|
-
}
|
|
606
|
-
if (!rec.config.llms.some((entry) => entry.name === arg)) {
|
|
607
|
-
return `Unknown model "${arg}".\n\n${this.#listModelsText(rec)}`;
|
|
608
|
-
}
|
|
609
|
-
if (arg === currentModelName(rec.config)) {
|
|
610
|
-
return `Already using model "${arg}".`;
|
|
611
|
-
}
|
|
612
|
-
try {
|
|
613
|
-
await this.#applyModelChange(rec, arg);
|
|
614
|
-
}
|
|
615
|
-
catch (error) {
|
|
616
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
617
|
-
return `Could not switch to model "${arg}": ${message}`;
|
|
618
|
-
}
|
|
619
|
-
await patchSessionMeta(this.#acpRoot, sessionId, { model: arg });
|
|
620
|
-
await this.#sendUpdate(client, sessionId, {
|
|
621
|
-
sessionUpdate: "config_option_update",
|
|
622
|
-
configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
|
|
623
|
-
});
|
|
624
|
-
return `Switched model to "${arg}".`;
|
|
625
|
-
}
|
|
626
|
-
#listModelsText(rec) {
|
|
627
|
-
const current = currentModelName(rec.config);
|
|
628
|
-
const lines = rec.config.llms.map((entry) => {
|
|
629
|
-
const marker = entry.name === current ? "*" : "-";
|
|
630
|
-
return `${marker} ${entry.name} (${entry.provider}/${entry.options.model})`;
|
|
631
|
-
});
|
|
632
|
-
return [
|
|
633
|
-
`Current model: ${current ?? "(none)"}`,
|
|
634
|
-
"Available models:",
|
|
635
|
-
...lines,
|
|
636
|
-
'Use "/model <name>" to switch.',
|
|
637
|
-
].join("\n");
|
|
638
|
-
}
|
|
639
|
-
async #commandSetEffort(client, sessionId, rec, args) {
|
|
640
|
-
const current = currentReasoningEffort(rec.config);
|
|
641
|
-
const arg = args.trim();
|
|
642
|
-
if (!arg) {
|
|
643
|
-
return `Current reasoning effort: "${current ?? REASONING_EFFORT_OFF}". Use ${REASONING_EFFORT_LEVELS.join(", ")} or ${REASONING_EFFORT_OFF}.`;
|
|
644
|
-
}
|
|
645
|
-
const parsed = parseReasoningEffortArg(arg);
|
|
646
|
-
if (!parsed) {
|
|
647
|
-
return `Unknown effort "${arg}". Use ${REASONING_EFFORT_LEVELS.join(", ")} or ${REASONING_EFFORT_OFF}.`;
|
|
648
|
-
}
|
|
649
|
-
if (parsed.value === current) {
|
|
650
|
-
return `Reasoning effort is already "${current ?? REASONING_EFFORT_OFF}".`;
|
|
651
|
-
}
|
|
652
|
-
try {
|
|
653
|
-
await this.#applyEffortChange(rec, parsed.value);
|
|
654
|
-
}
|
|
655
|
-
catch (error) {
|
|
656
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
657
|
-
return `Could not set reasoning effort: ${message}`;
|
|
658
|
-
}
|
|
659
|
-
await this.#sendUpdate(client, sessionId, {
|
|
660
|
-
sessionUpdate: "config_option_update",
|
|
661
|
-
configOptions: buildSessionConfigOptions(rec.config, resolveSessionMode(getModeState(rec.agent).mode)),
|
|
662
|
-
});
|
|
663
|
-
return `Set reasoning effort to "${parsed.value ?? REASONING_EFFORT_OFF}".`;
|
|
664
|
-
}
|
|
665
|
-
async #commandSetYolo(sessionId, rec, args) {
|
|
666
|
-
const arg = args.trim();
|
|
667
|
-
if (!arg) {
|
|
668
|
-
return `Usage: /yolo <on|off>. Auto-approve is currently ${isYoloEnabled(rec.agent) ? "on" : "off"}.`;
|
|
669
|
-
}
|
|
670
|
-
const enabled = parseYoloToggle(arg);
|
|
671
|
-
if (enabled === undefined) {
|
|
672
|
-
return `Unknown value "${arg}". Use on or off.`;
|
|
673
|
-
}
|
|
674
|
-
if (isYoloEnabled(rec.agent) === enabled) {
|
|
675
|
-
return `Auto-approve is already ${enabled ? "on" : "off"}.`;
|
|
676
|
-
}
|
|
677
|
-
setYoloEnabled(rec.agent, enabled);
|
|
678
|
-
await patchSessionMeta(this.#acpRoot, sessionId, { yolo: enabled });
|
|
679
|
-
return `Auto-approve tools ${enabled ? "enabled" : "disabled"}.`;
|
|
680
|
-
}
|
|
681
733
|
async #commandCompact(rec) {
|
|
682
734
|
const conversationManager = getAgentConversationManager(rec.agent);
|
|
683
735
|
if (!conversationManager) {
|
|
@@ -717,6 +769,28 @@ export class HoomanAcpAgent {
|
|
|
717
769
|
/* ignore */
|
|
718
770
|
}
|
|
719
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Queue guidance for the currently active turn via {@link ChatTurnSteeringController}
|
|
774
|
+
* rather than starting a new turn. Echoes the steered text back to the
|
|
775
|
+
* client tagged with `_meta["hoomanjs/steered"]` so it can be shown inline
|
|
776
|
+
* in the transcript. Errors (no active turn) surface as a normal RPC error.
|
|
777
|
+
*/
|
|
778
|
+
async #steerActiveTurn(client, rec, params) {
|
|
779
|
+
if (!this.#isTurnActive(rec)) {
|
|
780
|
+
throw RequestError.invalidParams({
|
|
781
|
+
message: "No active turn to steer.",
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
const text = acpPromptEchoText(params.prompt);
|
|
785
|
+
rec.steering.queue([{ text, attachments: [] }]);
|
|
786
|
+
await this.#sendUpdate(client, params.sessionId, {
|
|
787
|
+
sessionUpdate: "user_message_chunk",
|
|
788
|
+
content: { type: "text", text },
|
|
789
|
+
messageId: crypto.randomUUID(),
|
|
790
|
+
_meta: { "hoomanjs/steered": true },
|
|
791
|
+
});
|
|
792
|
+
return { stopReason: "end_turn" };
|
|
793
|
+
}
|
|
720
794
|
/** Run one prompt turn: stream model output, tool calls, and a stop reason. */
|
|
721
795
|
async prompt(params) {
|
|
722
796
|
const rec = this.#sessions.get(params.sessionId);
|
|
@@ -724,6 +798,12 @@ export class HoomanAcpAgent {
|
|
|
724
798
|
throw RequestError.invalidParams({ sessionId: params.sessionId });
|
|
725
799
|
}
|
|
726
800
|
const client = this.#requireClient();
|
|
801
|
+
// A client-side "steer" request injects guidance into the *currently
|
|
802
|
+
// running* turn (via the steering intervention) instead of queuing a
|
|
803
|
+
// brand new one. See `_meta["hoomanjs/steer"]` extensibility contract.
|
|
804
|
+
if (params._meta?.["hoomanjs/steer"] === true) {
|
|
805
|
+
return this.#steerActiveTurn(client, rec, params);
|
|
806
|
+
}
|
|
727
807
|
// Serialize concurrent prompt turns for the same session.
|
|
728
808
|
const prevExclusive = rec.promptExclusive;
|
|
729
809
|
let releaseExclusive;
|
|
@@ -737,6 +817,7 @@ export class HoomanAcpAgent {
|
|
|
737
817
|
rec.streamedToolCallIds.clear();
|
|
738
818
|
rec.streamingToolInputJson.clear();
|
|
739
819
|
rec.lastStreamToolUseId = null;
|
|
820
|
+
rec.currentAssistantMessageId = null;
|
|
740
821
|
let stopReason = "end_turn";
|
|
741
822
|
try {
|
|
742
823
|
const echo = acpPromptEchoText(params.prompt);
|
|
@@ -744,6 +825,7 @@ export class HoomanAcpAgent {
|
|
|
744
825
|
await this.#sendUpdate(client, params.sessionId, {
|
|
745
826
|
sessionUpdate: "user_message_chunk",
|
|
746
827
|
content: { type: "text", text: echo },
|
|
828
|
+
messageId: crypto.randomUUID(),
|
|
747
829
|
});
|
|
748
830
|
await this.#maybeDeriveTitle(client, params.sessionId, echo);
|
|
749
831
|
}
|
|
@@ -751,11 +833,12 @@ export class HoomanAcpAgent {
|
|
|
751
833
|
// (no model turn); `/init` rewrites the prompt and runs normally.
|
|
752
834
|
const command = parseAcpSlashCommand(echo);
|
|
753
835
|
if (command && command.name !== "init") {
|
|
754
|
-
const reply = await this.#runControlCommand(
|
|
836
|
+
const reply = await this.#runControlCommand(rec, command);
|
|
755
837
|
if (reply) {
|
|
756
838
|
await this.#sendUpdate(client, params.sessionId, {
|
|
757
839
|
sessionUpdate: "agent_message_chunk",
|
|
758
840
|
content: { type: "text", text: reply },
|
|
841
|
+
messageId: crypto.randomUUID(),
|
|
759
842
|
});
|
|
760
843
|
}
|
|
761
844
|
return { stopReason: "end_turn" };
|
|
@@ -826,6 +909,7 @@ export class HoomanAcpAgent {
|
|
|
826
909
|
}
|
|
827
910
|
else if (ev.type === "agentResultEvent") {
|
|
828
911
|
stopReason = toAcpStopReason(ev.result.stopReason);
|
|
912
|
+
await this.#sendUsageUpdate(client, params.sessionId, rec, ev.result.contextSize);
|
|
829
913
|
}
|
|
830
914
|
iter = await stream.next();
|
|
831
915
|
}
|
|
@@ -842,6 +926,7 @@ export class HoomanAcpAgent {
|
|
|
842
926
|
await this.#sendUpdate(client, params.sessionId, {
|
|
843
927
|
sessionUpdate: "agent_message_chunk",
|
|
844
928
|
content: { type: "text", text: `\n[error] ${message}\n` },
|
|
929
|
+
messageId: this.#assistantMessageId(rec),
|
|
845
930
|
});
|
|
846
931
|
stopReason = "refusal";
|
|
847
932
|
}
|
|
@@ -857,10 +942,13 @@ export class HoomanAcpAgent {
|
|
|
857
942
|
rec.turnAbort = null;
|
|
858
943
|
// Apply mode/model changes requested mid-turn before releasing the gate
|
|
859
944
|
// so the next turn sees the settled state.
|
|
860
|
-
await this.#flushPendingSettings(rec);
|
|
945
|
+
await this.#flushPendingSettings(rec, client, params.sessionId);
|
|
861
946
|
releaseExclusive();
|
|
947
|
+
// Conversation history is persisted by the Strands session manager
|
|
948
|
+
// (snapshot save on AfterInvocation, which fires even on error/cancel);
|
|
949
|
+
// only the index's `updatedAt` needs bumping for `session/list` order.
|
|
862
950
|
try {
|
|
863
|
-
await
|
|
951
|
+
await touchSessionEntry(this.#acpRoot, params.sessionId);
|
|
864
952
|
}
|
|
865
953
|
catch {
|
|
866
954
|
/* ignore */
|
|
@@ -868,6 +956,46 @@ export class HoomanAcpAgent {
|
|
|
868
956
|
}
|
|
869
957
|
return { stopReason };
|
|
870
958
|
}
|
|
959
|
+
/**
|
|
960
|
+
* Emit a `usage_update` at the end of a turn:
|
|
961
|
+
* - `used`: context tokens of the latest request (additive prompt total,
|
|
962
|
+
* so cache reads/writes count regardless of provider reporting style).
|
|
963
|
+
* - `size`: the context window resolved from the model's `billing` config /
|
|
964
|
+
* the models.dev catalog; `0` ("unknown") when unresolved, in which case
|
|
965
|
+
* clients should not render a used/size percentage.
|
|
966
|
+
* - `cost`: cumulative session USD, only while every priced request had
|
|
967
|
+
* resolved rates — otherwise omitted rather than reporting a lowball.
|
|
968
|
+
* - `_meta["hoomanjs/tokens"]`: cumulative token totals for the session
|
|
969
|
+
* (mirroring the CLI TUI's `in`/`cin`/`out` billing meter).
|
|
970
|
+
*/
|
|
971
|
+
#sendUsageUpdate(client, sessionId, rec, used) {
|
|
972
|
+
const contextUsed = rec.lastContextTokens ?? used;
|
|
973
|
+
if (contextUsed === undefined) {
|
|
974
|
+
return Promise.resolve();
|
|
975
|
+
}
|
|
976
|
+
const tokens = rec.cumulativeUsage;
|
|
977
|
+
const includeCost = rec.billing?.costs !== undefined && !rec.costUnpriced;
|
|
978
|
+
return this.#sendUpdate(client, sessionId, {
|
|
979
|
+
sessionUpdate: "usage_update",
|
|
980
|
+
used: contextUsed,
|
|
981
|
+
size: rec.billing?.context ?? 0,
|
|
982
|
+
...(includeCost && {
|
|
983
|
+
cost: { amount: rec.cumulativeCostUsd, currency: "USD" },
|
|
984
|
+
}),
|
|
985
|
+
_meta: {
|
|
986
|
+
"hoomanjs/tokens": {
|
|
987
|
+
input: tokens.inputTokens,
|
|
988
|
+
output: tokens.outputTokens,
|
|
989
|
+
...(tokens.cacheReadInputTokens !== undefined && {
|
|
990
|
+
cacheRead: tokens.cacheReadInputTokens,
|
|
991
|
+
}),
|
|
992
|
+
...(tokens.cacheWriteInputTokens !== undefined && {
|
|
993
|
+
cacheWrite: tokens.cacheWriteInputTokens,
|
|
994
|
+
}),
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
}
|
|
871
999
|
/**
|
|
872
1000
|
* Surface the agent's todo list as an ACP `plan` update. Sends the complete
|
|
873
1001
|
* list, as the client replaces the plan wholesale on each update. The
|
|
@@ -892,7 +1020,7 @@ export class HoomanAcpAgent {
|
|
|
892
1020
|
*/
|
|
893
1021
|
async #syncCurrentMode(client, sessionId, rec) {
|
|
894
1022
|
const modeId = resolveSessionMode(getModeState(rec.agent).mode);
|
|
895
|
-
await
|
|
1023
|
+
await patchSessionEntry(this.#acpRoot, sessionId, { sessionMode: modeId });
|
|
896
1024
|
await this.#sendUpdate(client, sessionId, {
|
|
897
1025
|
sessionUpdate: "current_mode_update",
|
|
898
1026
|
currentModeId: modeId,
|
|
@@ -900,16 +1028,20 @@ export class HoomanAcpAgent {
|
|
|
900
1028
|
// Config Options supersede modes; mirror the change for config-aware clients.
|
|
901
1029
|
await this.#sendUpdate(client, sessionId, {
|
|
902
1030
|
sessionUpdate: "config_option_update",
|
|
903
|
-
configOptions: buildSessionConfigOptions(rec.config, modeId),
|
|
1031
|
+
configOptions: buildSessionConfigOptions(rec.config, modeId, isYoloEnabled(rec.agent)),
|
|
904
1032
|
});
|
|
905
1033
|
}
|
|
906
|
-
/**
|
|
1034
|
+
/**
|
|
1035
|
+
* Derive + persist an instant placeholder title from the first meaningful
|
|
1036
|
+
* prompt echo. The session-title plugin upgrades it with an AI-generated
|
|
1037
|
+
* summary once the turn runs (see `onSessionTitle` in the bootstrap meta).
|
|
1038
|
+
*/
|
|
907
1039
|
async #maybeDeriveTitle(client, sessionId, echo) {
|
|
908
|
-
const
|
|
909
|
-
const needsTitle =
|
|
910
|
-
(
|
|
911
|
-
|
|
912
|
-
String(
|
|
1040
|
+
const entry = await readSessionEntry(this.#acpRoot, sessionId);
|
|
1041
|
+
const needsTitle = entry &&
|
|
1042
|
+
(entry.title === undefined ||
|
|
1043
|
+
entry.title === null ||
|
|
1044
|
+
String(entry.title).trim() === "");
|
|
913
1045
|
if (!needsTitle) {
|
|
914
1046
|
return;
|
|
915
1047
|
}
|
|
@@ -917,7 +1049,7 @@ export class HoomanAcpAgent {
|
|
|
917
1049
|
if (!title) {
|
|
918
1050
|
return;
|
|
919
1051
|
}
|
|
920
|
-
await
|
|
1052
|
+
await patchSessionEntry(this.#acpRoot, sessionId, { title });
|
|
921
1053
|
await this.#sendUpdate(client, sessionId, {
|
|
922
1054
|
sessionUpdate: "session_info_update",
|
|
923
1055
|
title,
|
|
@@ -931,6 +1063,12 @@ export class HoomanAcpAgent {
|
|
|
931
1063
|
return;
|
|
932
1064
|
}
|
|
933
1065
|
switch (inner.type) {
|
|
1066
|
+
case "modelMessageStartEvent": {
|
|
1067
|
+
// A new assistant message starts a fresh `messageId` shared by all of
|
|
1068
|
+
// its `agent_message_chunk`/`agent_thought_chunk` updates.
|
|
1069
|
+
rec.currentAssistantMessageId = crypto.randomUUID();
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
934
1072
|
case "modelContentBlockStartEvent": {
|
|
935
1073
|
const start = inner.start;
|
|
936
1074
|
if (start?.type === "toolUseStart") {
|
|
@@ -954,6 +1092,7 @@ export class HoomanAcpAgent {
|
|
|
954
1092
|
await this.#sendUpdate(client, sessionId, {
|
|
955
1093
|
sessionUpdate: "agent_message_chunk",
|
|
956
1094
|
content: { type: "text", text: delta.text },
|
|
1095
|
+
messageId: this.#assistantMessageId(rec),
|
|
957
1096
|
});
|
|
958
1097
|
return;
|
|
959
1098
|
}
|
|
@@ -962,6 +1101,7 @@ export class HoomanAcpAgent {
|
|
|
962
1101
|
await this.#sendUpdate(client, sessionId, {
|
|
963
1102
|
sessionUpdate: "agent_thought_chunk",
|
|
964
1103
|
content: { type: "text", text: delta.text },
|
|
1104
|
+
messageId: this.#assistantMessageId(rec),
|
|
965
1105
|
});
|
|
966
1106
|
}
|
|
967
1107
|
return;
|
|
@@ -990,11 +1130,34 @@ export class HoomanAcpAgent {
|
|
|
990
1130
|
type: "text",
|
|
991
1131
|
text: `[citations: ${n} reference(s)]\n`,
|
|
992
1132
|
},
|
|
1133
|
+
messageId: this.#assistantMessageId(rec),
|
|
993
1134
|
});
|
|
994
1135
|
}
|
|
995
1136
|
}
|
|
996
1137
|
return;
|
|
997
1138
|
}
|
|
1139
|
+
case "modelMetadataEvent": {
|
|
1140
|
+
if (inner.usage) {
|
|
1141
|
+
// Providers like OpenAI/Moonshot report input inclusive of cache
|
|
1142
|
+
// reads; normalize to the additive shape so `in`/`cin` don't
|
|
1143
|
+
// double-count in the client's meter.
|
|
1144
|
+
const additive = toAdditiveUsage(inner.usage, rec.agent.model);
|
|
1145
|
+
accumulateUsage(rec.cumulativeUsage, additive);
|
|
1146
|
+
rec.lastContextTokens = contextTokensFromUsage(additive);
|
|
1147
|
+
// Session cost accrues per request at the rates of the model that
|
|
1148
|
+
// served it; once a request with usage runs unpriced, the total is
|
|
1149
|
+
// incomplete and cost reporting stops for the session.
|
|
1150
|
+
if ((additive.totalTokens ?? 0) > 0) {
|
|
1151
|
+
if (rec.billing?.costs) {
|
|
1152
|
+
rec.cumulativeCostUsd += computeUsageCostUsd(additive, rec.billing.costs);
|
|
1153
|
+
}
|
|
1154
|
+
else {
|
|
1155
|
+
rec.costUnpriced = true;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
998
1161
|
case "modelRedactionEvent": {
|
|
999
1162
|
const replace = inner.outputRedaction?.replaceContent ??
|
|
1000
1163
|
inner.inputRedaction?.replaceContent;
|
|
@@ -1002,6 +1165,7 @@ export class HoomanAcpAgent {
|
|
|
1002
1165
|
await this.#sendUpdate(client, sessionId, {
|
|
1003
1166
|
sessionUpdate: "agent_message_chunk",
|
|
1004
1167
|
content: { type: "text", text: replace },
|
|
1168
|
+
messageId: this.#assistantMessageId(rec),
|
|
1005
1169
|
});
|
|
1006
1170
|
}
|
|
1007
1171
|
return;
|
|
@@ -1010,6 +1174,13 @@ export class HoomanAcpAgent {
|
|
|
1010
1174
|
return;
|
|
1011
1175
|
}
|
|
1012
1176
|
}
|
|
1177
|
+
/** Lazily assign a `messageId` for the in-flight assistant message. */
|
|
1178
|
+
#assistantMessageId(rec) {
|
|
1179
|
+
if (!rec.currentAssistantMessageId) {
|
|
1180
|
+
rec.currentAssistantMessageId = crypto.randomUUID();
|
|
1181
|
+
}
|
|
1182
|
+
return rec.currentAssistantMessageId;
|
|
1183
|
+
}
|
|
1013
1184
|
#streamToolUseIdForInputDelta(rec) {
|
|
1014
1185
|
if (rec.lastStreamToolUseId &&
|
|
1015
1186
|
rec.streamedToolCallIds.has(rec.lastStreamToolUseId)) {
|
|
@@ -1029,7 +1200,7 @@ export class HoomanAcpAgent {
|
|
|
1029
1200
|
}
|
|
1030
1201
|
return this.#client;
|
|
1031
1202
|
}
|
|
1032
|
-
async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel) {
|
|
1203
|
+
async #bootstrapSession(sessionId, cwd, userId, mcpServers, mode, preferredModel, vscode = false) {
|
|
1033
1204
|
const client = this.#requireClient();
|
|
1034
1205
|
const sessionConfig = createSessionConfig();
|
|
1035
1206
|
if (preferredModel &&
|
|
@@ -1042,18 +1213,37 @@ export class HoomanAcpAgent {
|
|
|
1042
1213
|
})),
|
|
1043
1214
|
});
|
|
1044
1215
|
}
|
|
1216
|
+
const steering = new ChatTurnSteeringController();
|
|
1045
1217
|
const { config, agent, mcp: { manager }, } = await bootstrap("acp", {
|
|
1046
1218
|
userId,
|
|
1047
1219
|
sessionId,
|
|
1048
1220
|
mode,
|
|
1221
|
+
interventions: [createChatTurnSteeringIntervention(steering)],
|
|
1049
1222
|
createInterventions: () => [
|
|
1050
1223
|
createAcpToolApprovalIntervention(client, sessionId, () => this.#sessions.get(sessionId)?.streamedToolCallIds ??
|
|
1051
1224
|
EMPTY_STREAMED_TOOL_CALL_IDS),
|
|
1052
1225
|
],
|
|
1053
|
-
|
|
1226
|
+
// The session-title plugin generated an AI title (upgrading the
|
|
1227
|
+
// echo-derived placeholder): persist it and notify the client.
|
|
1228
|
+
onSessionTitle: async (title) => {
|
|
1229
|
+
await patchSessionEntry(this.#acpRoot, sessionId, { title });
|
|
1230
|
+
await this.#sendUpdate(client, sessionId, {
|
|
1231
|
+
sessionUpdate: "session_info_update",
|
|
1232
|
+
title,
|
|
1233
|
+
updatedAt: new Date().toISOString(),
|
|
1234
|
+
});
|
|
1235
|
+
},
|
|
1236
|
+
acp: {
|
|
1237
|
+
mcpServers: mcpServers ?? [],
|
|
1238
|
+
vscode,
|
|
1239
|
+
cwd,
|
|
1240
|
+
},
|
|
1054
1241
|
}, false, sessionConfig);
|
|
1055
1242
|
this.#registerTextFsBackend(agent, client, sessionId);
|
|
1056
1243
|
this.#registerTerminalBackend(agent, client, sessionId);
|
|
1244
|
+
setAskUserBackend(agent, createAcpAskUserBackend(client, sessionId));
|
|
1245
|
+
const activeLlm = config.llm;
|
|
1246
|
+
const billing = await resolveLlmBilling(activeLlm.billing, activeLlm.llmOptions.model).catch(() => null);
|
|
1057
1247
|
return {
|
|
1058
1248
|
cwd,
|
|
1059
1249
|
agent,
|
|
@@ -1061,14 +1251,21 @@ export class HoomanAcpAgent {
|
|
|
1061
1251
|
mcpDisconnect: () => manager.disconnect().catch(() => undefined),
|
|
1062
1252
|
turnAbort: null,
|
|
1063
1253
|
promptExclusive: Promise.resolve(),
|
|
1254
|
+
steering,
|
|
1064
1255
|
streamedToolCallIds: new Set(),
|
|
1065
1256
|
streamingToolInputJson: new Map(),
|
|
1066
1257
|
lastStreamToolUseId: null,
|
|
1067
1258
|
terminalByToolCall: new Map(),
|
|
1259
|
+
currentAssistantMessageId: null,
|
|
1068
1260
|
pendingModeReapply: false,
|
|
1069
1261
|
pendingModelRebuild: false,
|
|
1070
1262
|
pendingPersistModel: null,
|
|
1071
1263
|
pendingPersistEffort: null,
|
|
1264
|
+
cumulativeUsage: createEmptyUsage(),
|
|
1265
|
+
billing,
|
|
1266
|
+
cumulativeCostUsd: 0,
|
|
1267
|
+
costUnpriced: false,
|
|
1268
|
+
lastContextTokens: undefined,
|
|
1072
1269
|
};
|
|
1073
1270
|
}
|
|
1074
1271
|
/**
|
|
@@ -1224,10 +1421,12 @@ export function createAcpApp(agent) {
|
|
|
1224
1421
|
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
|
|
1225
1422
|
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
|
|
1226
1423
|
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params, ctx.client))
|
|
1424
|
+
.onRequest(methods.agent.session.resume, (ctx) => agent.resumeSession(ctx.params, ctx.client))
|
|
1425
|
+
.onRequest(methods.agent.session.close, (ctx) => agent.closeSession(ctx.params))
|
|
1227
1426
|
.onRequest(methods.agent.session.list, (ctx) => agent.listSessions(ctx.params))
|
|
1228
1427
|
.onRequest(methods.agent.session.delete, (ctx) => agent.deleteSession(ctx.params))
|
|
1229
|
-
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params))
|
|
1230
|
-
.onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params))
|
|
1428
|
+
.onRequest(methods.agent.session.setMode, (ctx) => agent.setSessionMode(ctx.params, ctx.client))
|
|
1429
|
+
.onRequest(methods.agent.session.setConfigOption, (ctx) => agent.setSessionConfigOption(ctx.params, ctx.client))
|
|
1231
1430
|
.onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
|
|
1232
1431
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params));
|
|
1233
1432
|
}
|