@shawnstack/quickforge 1.3.13 → 1.3.15
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 +348 -348
- package/dist/assets/{anthropic-Ckozj5YL.js → anthropic-BT3d4VOP.js} +1 -1
- package/dist/assets/{azure-openai-responses-BwnfX0zX.js → azure-openai-responses-48tpKUXk.js} +1 -1
- package/dist/assets/{google-C68FfCgv.js → google-BYapAGwA.js} +1 -1
- package/dist/assets/{google-gemini-cli-Bvmj_VP7.js → google-gemini-cli-BnkyGSNu.js} +1 -1
- package/dist/assets/{google-vertex-DfBcqFGy.js → google-vertex-E_DV02Ii.js} +1 -1
- package/dist/assets/{index-Bx9iFrzC.js → index-BLH5UHZ2.js} +536 -474
- package/dist/assets/index-t6ITXfOr.css +3 -0
- package/dist/assets/{mistral-soe30nal.js → mistral-CTSjsKBm.js} +1 -1
- package/dist/assets/{openai-codex-responses-BX3Fx3LC.js → openai-codex-responses-Db9Q_SPJ.js} +1 -1
- package/dist/assets/{openai-completions-CYI6ynqy.js → openai-completions-CL6yyDHL.js} +1 -1
- package/dist/assets/{openai-responses-X7pjajQb.js → openai-responses-BBcr7NqZ.js} +1 -1
- package/dist/assets/{openai-responses-shared-Cx1dCwfa.js → openai-responses-shared-BMUh_Lci.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/agent-manager.mjs +142 -13
- package/server/auto-compaction.mjs +249 -0
- package/server/routes/agent.mjs +27 -0
- package/server/tools/definitions.mjs +1 -1
- package/server/tools/index.mjs +745 -712
- package/dist/assets/index-kYsI9py3.css +0 -3
package/package.json
CHANGED
package/server/agent-manager.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events'
|
|
|
2
2
|
import { randomUUID } from 'node:crypto'
|
|
3
3
|
import { Agent } from '@mariozechner/pi-agent-core'
|
|
4
4
|
import { streamSimpleWithAiHttpLogging } from './ai-http-logger.mjs'
|
|
5
|
-
import { toolHandlers, loadSkillToolContext } from './tools/index.mjs'
|
|
5
|
+
import { toolHandlers, loadSkillToolContext, abortRunningCommand } from './tools/index.mjs'
|
|
6
6
|
import { createSkillTools, workspaceTools } from './tools/definitions.mjs'
|
|
7
7
|
import { callMcpTool, createMcpToolDefinitions, isMcpToolName } from './mcp/registry.mjs'
|
|
8
8
|
import { projectContextFromId, readProjectConfig } from './project-config.mjs'
|
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
parseCompactArgs,
|
|
16
16
|
saveCompactBackup,
|
|
17
17
|
} from './conversation-compaction.mjs'
|
|
18
|
+
import {
|
|
19
|
+
buildAutoCompactLoopMessages,
|
|
20
|
+
maybeAutoCompactSession,
|
|
21
|
+
} from './auto-compaction.mjs'
|
|
18
22
|
import {
|
|
19
23
|
handleInternalCommand,
|
|
20
24
|
parseInternalCommandInvocation,
|
|
@@ -47,12 +51,13 @@ function wrapToolDefinition(definition, context, toolPermissions) {
|
|
|
47
51
|
|
|
48
52
|
const startedAt = Date.now()
|
|
49
53
|
const startedAtPerf = performance.now()
|
|
50
|
-
const result = await handler(params || {}, context, { signal, onUpdate })
|
|
54
|
+
const result = await handler(params || {}, context, { signal, onUpdate, toolCallId: _toolCallId })
|
|
51
55
|
const finishedAt = Date.now()
|
|
52
56
|
const durationMs = Math.max(0, Math.round(performance.now() - startedAtPerf))
|
|
57
|
+
const details = mergeQuickForgeTiming(result.details, { startedAt, finishedAt, durationMs })
|
|
53
58
|
return {
|
|
54
59
|
content: [{ type: 'text', text: result.content }],
|
|
55
|
-
details:
|
|
60
|
+
details: isPlainObject(details) ? { ...details, toolCallId: _toolCallId } : details,
|
|
56
61
|
}
|
|
57
62
|
},
|
|
58
63
|
}
|
|
@@ -137,6 +142,7 @@ const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes for tool approval
|
|
|
137
142
|
const commandRestrictedTools = new Set(['write_file', 'edit_file', 'run_command'])
|
|
138
143
|
const safeReadTools = new Set(['read_file', 'grep_files'])
|
|
139
144
|
const pendingApprovals = new Map() // toolCallId → { resolve, reject, sessionId, toolName, args, timeout }
|
|
145
|
+
const pendingAutoCompactApprovals = new Map() // approvalId → { resolve, reject, sessionId, timeout }
|
|
140
146
|
|
|
141
147
|
function createCommandToolPermissions(session) {
|
|
142
148
|
return (toolName) => {
|
|
@@ -220,6 +226,61 @@ function createApprovalPromise(session, toolCallId, toolName, args) {
|
|
|
220
226
|
})
|
|
221
227
|
}
|
|
222
228
|
|
|
229
|
+
function createAutoCompactApprovalPromise(session, details = {}) {
|
|
230
|
+
if (!session) return Promise.resolve(false)
|
|
231
|
+
const approvalId = randomUUID()
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
let settled = false
|
|
234
|
+
const timeout = setTimeout(() => {
|
|
235
|
+
if (settled) return
|
|
236
|
+
settled = true
|
|
237
|
+
pendingAutoCompactApprovals.delete(approvalId)
|
|
238
|
+
resolve(false)
|
|
239
|
+
}, APPROVAL_TIMEOUT_MS)
|
|
240
|
+
|
|
241
|
+
const cleanup = () => {
|
|
242
|
+
clearTimeout(timeout)
|
|
243
|
+
if (settled) return
|
|
244
|
+
settled = true
|
|
245
|
+
pendingAutoCompactApprovals.delete(approvalId)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const signal = session.agent.signal
|
|
249
|
+
if (signal) {
|
|
250
|
+
if (signal.aborted) {
|
|
251
|
+
cleanup()
|
|
252
|
+
reject(new Error('Run aborted'))
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
const onAbort = () => {
|
|
256
|
+
cleanup()
|
|
257
|
+
reject(new Error('Run aborted'))
|
|
258
|
+
}
|
|
259
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
pendingAutoCompactApprovals.set(approvalId, {
|
|
263
|
+
resolve: (approved) => {
|
|
264
|
+
cleanup()
|
|
265
|
+
resolve(approved === true)
|
|
266
|
+
},
|
|
267
|
+
reject: (err) => {
|
|
268
|
+
cleanup()
|
|
269
|
+
reject(err)
|
|
270
|
+
},
|
|
271
|
+
sessionId: session.sessionId,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
emitSessionEvent(session, {
|
|
275
|
+
type: 'auto_compact_approval_required',
|
|
276
|
+
approvalId,
|
|
277
|
+
usage: details.usage,
|
|
278
|
+
thresholdPercent: details.settings?.thresholdPercent,
|
|
279
|
+
keepRecentTurns: details.settings?.keepRecentTurns,
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
223
284
|
function assistantTextMessage(text, model) {
|
|
224
285
|
return {
|
|
225
286
|
role: 'assistant',
|
|
@@ -294,10 +355,13 @@ function addToolTimingToEvent(session, event) {
|
|
|
294
355
|
|
|
295
356
|
function updateSessionMessages(session, messages) {
|
|
296
357
|
session.agent.state.messages = messages
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resetSessionCompaction(session) {
|
|
361
|
+
session.contextCompaction = null
|
|
362
|
+
session.lastAutoCompactAt = null
|
|
363
|
+
session.lastTransformedContextMessages = null
|
|
364
|
+
session.autoCompacting = false
|
|
301
365
|
}
|
|
302
366
|
|
|
303
367
|
function finishManualSessionRun(session, status, errorMessage) {
|
|
@@ -464,6 +528,7 @@ async function clearSession(session) {
|
|
|
464
528
|
}
|
|
465
529
|
|
|
466
530
|
updateSessionMessages(session, [])
|
|
531
|
+
resetSessionCompaction(session)
|
|
467
532
|
session.status = 'idle'
|
|
468
533
|
session.startedAt = null
|
|
469
534
|
session.finishedAt = new Date().toISOString()
|
|
@@ -576,8 +641,19 @@ function compactedContextMessages(messages) {
|
|
|
576
641
|
return index >= 0 ? messages.slice(index) : messages
|
|
577
642
|
}
|
|
578
643
|
|
|
579
|
-
function
|
|
580
|
-
|
|
644
|
+
async function transformSessionContext(session, messages, signal) {
|
|
645
|
+
await maybeAutoCompactSession({
|
|
646
|
+
session,
|
|
647
|
+
messages,
|
|
648
|
+
signal,
|
|
649
|
+
emitSessionEvent,
|
|
650
|
+
persistSession,
|
|
651
|
+
logger,
|
|
652
|
+
confirmAutoCompact: createAutoCompactApprovalPromise,
|
|
653
|
+
})
|
|
654
|
+
const transformedMessages = buildAutoCompactLoopMessages(session, messages)
|
|
655
|
+
session.lastTransformedContextMessages = transformedMessages
|
|
656
|
+
return applyActiveCommandPrompt(compactedContextMessages(transformedMessages), session?.activeCommandPrompt)
|
|
581
657
|
}
|
|
582
658
|
|
|
583
659
|
export const agentEvents = new EventEmitter()
|
|
@@ -633,6 +709,7 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
633
709
|
systemPrompt = null,
|
|
634
710
|
title = 'New chat',
|
|
635
711
|
createdAt = new Date().toISOString(),
|
|
712
|
+
contextCompaction = null,
|
|
636
713
|
} = config
|
|
637
714
|
|
|
638
715
|
// Resolve project context for tool calls
|
|
@@ -704,9 +781,9 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
704
781
|
sessionId,
|
|
705
782
|
convertToLlm: serverConvertToLlm,
|
|
706
783
|
onPayload: (payload) => {
|
|
707
|
-
restoreReasoningContentInPayload(payload, agent.state.messages, agent.state.model)
|
|
784
|
+
restoreReasoningContentInPayload(payload, session?.lastTransformedContextMessages || agent.state.messages, agent.state.model)
|
|
708
785
|
},
|
|
709
|
-
transformContext: (messages) =>
|
|
786
|
+
transformContext: (messages, signal) => transformSessionContext(session, messages, signal),
|
|
710
787
|
beforeToolCall: async (context) => {
|
|
711
788
|
const toolName = context.toolCall?.name
|
|
712
789
|
const toolCallId = context.toolCall?.id
|
|
@@ -756,6 +833,10 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
756
833
|
titleGenerated: false,
|
|
757
834
|
toolTimings: new Map(),
|
|
758
835
|
getApiKey,
|
|
836
|
+
contextCompaction,
|
|
837
|
+
lastTransformedContextMessages: null,
|
|
838
|
+
autoCompacting: false,
|
|
839
|
+
lastAutoCompactAt: null,
|
|
759
840
|
/** Track active SSE connections. Only one SSE stream allowed per session to prevent
|
|
760
841
|
* connection-pool exhaustion when two browser tabs load the same session. */
|
|
761
842
|
sseConnected: false,
|
|
@@ -816,8 +897,8 @@ export async function createAgent(sessionId, config = {}) {
|
|
|
816
897
|
* Persist session data to storage.
|
|
817
898
|
*/
|
|
818
899
|
async function persistSession(session) {
|
|
819
|
-
const { sessionId, agent, scope, projectId, title, createdAt, status, startedAt, finishedAt, model, thinkingLevel, yoloMode } = session
|
|
820
|
-
const messages =
|
|
900
|
+
const { sessionId, agent, scope, projectId, title, createdAt, status, startedAt, finishedAt, model, thinkingLevel, yoloMode, contextCompaction } = session
|
|
901
|
+
const messages = agent.state.messages
|
|
821
902
|
|
|
822
903
|
if (messages.length === 0) {
|
|
823
904
|
try {
|
|
@@ -847,6 +928,7 @@ async function persistSession(session) {
|
|
|
847
928
|
taskStatus: status,
|
|
848
929
|
taskStartedAt: startedAt,
|
|
849
930
|
taskFinishedAt: finishedAt,
|
|
931
|
+
contextCompaction: contextCompaction || undefined,
|
|
850
932
|
}
|
|
851
933
|
|
|
852
934
|
// Calculate usage
|
|
@@ -894,6 +976,13 @@ async function persistSession(session) {
|
|
|
894
976
|
taskStatus: status,
|
|
895
977
|
taskStartedAt: startedAt,
|
|
896
978
|
taskFinishedAt: finishedAt,
|
|
979
|
+
contextCompaction: contextCompaction ? {
|
|
980
|
+
compactedAt: contextCompaction.compactedAt,
|
|
981
|
+
compactedUpToIndex: contextCompaction.compactedUpToIndex,
|
|
982
|
+
keepRecentTurns: contextCompaction.keepRecentTurns,
|
|
983
|
+
thresholdPercent: contextCompaction.thresholdPercent,
|
|
984
|
+
usageBefore: contextCompaction.usageBefore,
|
|
985
|
+
} : undefined,
|
|
897
986
|
}
|
|
898
987
|
|
|
899
988
|
// Write to storage atomically (read-modify-write within queue)
|
|
@@ -947,6 +1036,7 @@ export async function rollbackSessionMessages(sessionId, rollbackMessageIndex) {
|
|
|
947
1036
|
|
|
948
1037
|
const nextMessages = messages.slice(0, rollbackIndex)
|
|
949
1038
|
updateSessionMessages(session, nextMessages)
|
|
1039
|
+
resetSessionCompaction(session)
|
|
950
1040
|
session.status = 'idle'
|
|
951
1041
|
session.finishedAt = new Date().toISOString()
|
|
952
1042
|
await persistSession(session)
|
|
@@ -971,6 +1061,7 @@ export async function replaceSessionMessages(sessionId, messages) {
|
|
|
971
1061
|
throw Object.assign(new Error('Generation is still running. Stop it or wait until it finishes before rolling back.'), { statusCode: 409 })
|
|
972
1062
|
}
|
|
973
1063
|
updateSessionMessages(session, Array.isArray(messages) ? messages : [])
|
|
1064
|
+
resetSessionCompaction(session)
|
|
974
1065
|
session.status = 'idle'
|
|
975
1066
|
session.finishedAt = new Date().toISOString()
|
|
976
1067
|
await persistSession(session)
|
|
@@ -1083,6 +1174,12 @@ export async function abortRun(sessionId) {
|
|
|
1083
1174
|
approval.reject(new Error('Run aborted'))
|
|
1084
1175
|
}
|
|
1085
1176
|
}
|
|
1177
|
+
for (const [approvalId, approval] of pendingAutoCompactApprovals) {
|
|
1178
|
+
if (approval.sessionId === sessionId) {
|
|
1179
|
+
approval.reject(new Error('Run aborted'))
|
|
1180
|
+
pendingAutoCompactApprovals.delete(approvalId)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1086
1183
|
|
|
1087
1184
|
session.agent.abort()
|
|
1088
1185
|
await session.agent.waitForIdle()
|
|
@@ -1160,6 +1257,7 @@ export function getSessionState(sessionId) {
|
|
|
1160
1257
|
finishedAt: session.finishedAt,
|
|
1161
1258
|
tools: session.agent.state.tools,
|
|
1162
1259
|
messages: session.agent.state.messages,
|
|
1260
|
+
contextCompaction: session.contextCompaction,
|
|
1163
1261
|
isStreaming: session.agent.state.isStreaming,
|
|
1164
1262
|
errorMessage: session.agent.state.errorMessage,
|
|
1165
1263
|
}
|
|
@@ -1255,6 +1353,7 @@ export async function restoreAgent(sessionId) {
|
|
|
1255
1353
|
messages: sessionData.messages || [],
|
|
1256
1354
|
title: sessionData.title || 'New chat',
|
|
1257
1355
|
createdAt: sessionData.createdAt,
|
|
1356
|
+
contextCompaction: sessionData.contextCompaction || null,
|
|
1258
1357
|
})
|
|
1259
1358
|
} catch (err) {
|
|
1260
1359
|
logger.error(`Failed to restore agent ${sessionId}:`, err, { sessionId })
|
|
@@ -1286,6 +1385,36 @@ export function rejectToolCall(sessionId, toolCallId) {
|
|
|
1286
1385
|
return { rejected: true, toolCallId }
|
|
1287
1386
|
}
|
|
1288
1387
|
|
|
1388
|
+
export function approveAutoCompact(sessionId, approvalId) {
|
|
1389
|
+
const approval = pendingAutoCompactApprovals.get(approvalId)
|
|
1390
|
+
if (!approval || approval.sessionId !== sessionId) {
|
|
1391
|
+
throw Object.assign(new Error('No pending auto compact approval for this session'), { statusCode: 404 })
|
|
1392
|
+
}
|
|
1393
|
+
approval.resolve(true)
|
|
1394
|
+
return { approved: true, approvalId }
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
export function rejectAutoCompact(sessionId, approvalId) {
|
|
1398
|
+
const approval = pendingAutoCompactApprovals.get(approvalId)
|
|
1399
|
+
if (!approval || approval.sessionId !== sessionId) {
|
|
1400
|
+
throw Object.assign(new Error('No pending auto compact approval for this session'), { statusCode: 404 })
|
|
1401
|
+
}
|
|
1402
|
+
approval.resolve(false)
|
|
1403
|
+
return { rejected: true, approvalId }
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
export function abortToolCall(sessionId, toolCallId) {
|
|
1407
|
+
const session = agentSessions.get(sessionId)
|
|
1408
|
+
if (!session) {
|
|
1409
|
+
throw Object.assign(new Error('Session not found'), { statusCode: 404 })
|
|
1410
|
+
}
|
|
1411
|
+
const aborted = abortRunningCommand(toolCallId)
|
|
1412
|
+
if (!aborted) {
|
|
1413
|
+
throw Object.assign(new Error('No running command for this tool call'), { statusCode: 404 })
|
|
1414
|
+
}
|
|
1415
|
+
return { aborted: true, toolCallId }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1289
1418
|
/**
|
|
1290
1419
|
* List all active sessions.
|
|
1291
1420
|
*/
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { readStore } from './storage.mjs'
|
|
2
|
+
import { compactConversation, saveCompactBackup } from './conversation-compaction.mjs'
|
|
3
|
+
|
|
4
|
+
export const AUTO_COMPACT_SETTINGS_KEY = 'auto-compact-settings'
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_AUTO_COMPACT_SETTINGS = {
|
|
7
|
+
enabled: false,
|
|
8
|
+
thresholdPercent: 80,
|
|
9
|
+
keepRecentTurns: 2,
|
|
10
|
+
minSourceChars: 1600,
|
|
11
|
+
requireConfirmation: true,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const AUTO_COMPACT_MIN_INTERVAL_MS = 30_000
|
|
15
|
+
|
|
16
|
+
function clampNumber(value, fallback, min, max) {
|
|
17
|
+
const parsed = Number(value)
|
|
18
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
19
|
+
return Math.min(max, Math.max(min, Math.round(parsed)))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeAutoCompactSettings(value) {
|
|
23
|
+
if (!value || typeof value !== 'object') return { ...DEFAULT_AUTO_COMPACT_SETTINGS }
|
|
24
|
+
return {
|
|
25
|
+
enabled: value.enabled === true,
|
|
26
|
+
thresholdPercent: clampNumber(value.thresholdPercent, DEFAULT_AUTO_COMPACT_SETTINGS.thresholdPercent, 50, 95),
|
|
27
|
+
keepRecentTurns: clampNumber(value.keepRecentTurns, DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns, 1, 20),
|
|
28
|
+
minSourceChars: clampNumber(value.minSourceChars, DEFAULT_AUTO_COMPACT_SETTINGS.minSourceChars, 0, 200000),
|
|
29
|
+
requireConfirmation: value.requireConfirmation !== false,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readAutoCompactSettings() {
|
|
34
|
+
const settings = await readStore('settings')
|
|
35
|
+
return normalizeAutoCompactSettings(settings?.[AUTO_COMPACT_SETTINGS_KEY])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function safeJson(value) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.stringify(value)
|
|
41
|
+
} catch {
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function estimateTextTokens(value) {
|
|
47
|
+
const text = String(value || '')
|
|
48
|
+
if (!text) return 0
|
|
49
|
+
const cjkChars = text.match(/[\u3400-\u9fff\uf900-\ufaff]/g)?.length ?? 0
|
|
50
|
+
const otherChars = Math.max(0, text.length - cjkChars)
|
|
51
|
+
return Math.ceil(cjkChars + otherChars / 3.5)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function contentToText(content) {
|
|
55
|
+
if (typeof content === 'string') return content
|
|
56
|
+
if (!Array.isArray(content)) return ''
|
|
57
|
+
return content.map((block) => {
|
|
58
|
+
if (!block || typeof block !== 'object') return ''
|
|
59
|
+
if (block.type === 'text') return block.text || ''
|
|
60
|
+
if (block.type === 'thinking') return block.thinking || ''
|
|
61
|
+
if (block.type === 'image') return `[image:${block.mimeType || 'unknown'}]`
|
|
62
|
+
if (block.type === 'toolCall') return `[toolCall:${block.name || 'unknown'}] ${safeJson(block.arguments)}`
|
|
63
|
+
return safeJson(block)
|
|
64
|
+
}).filter(Boolean).join('\n')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function estimateMessageTokens(message) {
|
|
68
|
+
if (!message || typeof message !== 'object') return 0
|
|
69
|
+
const parts = [message.role || '', contentToText(message.content)]
|
|
70
|
+
if (message.toolName) parts.push(message.toolName)
|
|
71
|
+
if (message.toolCallId) parts.push(message.toolCallId)
|
|
72
|
+
if (message.details !== undefined) parts.push(safeJson(message.details))
|
|
73
|
+
if (message.attachments !== undefined) parts.push(safeJson(message.attachments))
|
|
74
|
+
return estimateTextTokens(parts.join('\n'))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function estimateMessagesTokens(messages) {
|
|
78
|
+
return (Array.isArray(messages) ? messages : []).reduce((total, message) => total + estimateMessageTokens(message), 0)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function estimateMessagesChars(messages) {
|
|
82
|
+
return (Array.isArray(messages) ? messages : []).reduce((total, message) => {
|
|
83
|
+
if (!message || typeof message !== 'object') return total
|
|
84
|
+
return total + [message.role || '', contentToText(message.content), safeJson(message.details), safeJson(message.attachments)].join('\n').length
|
|
85
|
+
}, 0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function estimateContextUsage({ systemPrompt, messages, tools, model }) {
|
|
89
|
+
const contextWindow = Number(model?.contextWindow) || 0
|
|
90
|
+
const reservedOutputTokens = Math.max(0, Number(model?.maxTokens) || 4096)
|
|
91
|
+
const inputTokens =
|
|
92
|
+
estimateTextTokens(systemPrompt) +
|
|
93
|
+
estimateMessagesTokens(messages) +
|
|
94
|
+
estimateTextTokens(safeJson(tools))
|
|
95
|
+
const totalTokens = inputTokens + reservedOutputTokens
|
|
96
|
+
const percent = contextWindow > 0 ? Math.round((totalTokens / contextWindow) * 1000) / 10 : 0
|
|
97
|
+
return { inputTokens, reservedOutputTokens, totalTokens, contextWindow, percent }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isUserMessage(message) {
|
|
101
|
+
return message?.role === 'user' || message?.role === 'user-with-attachments'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tailStartForRecentTurns(messages, keepRecentTurns) {
|
|
105
|
+
const source = Array.isArray(messages) ? messages : []
|
|
106
|
+
let seenUserTurns = 0
|
|
107
|
+
for (let index = source.length - 1; index >= 0; index--) {
|
|
108
|
+
if (!isUserMessage(source[index])) continue
|
|
109
|
+
seenUserTurns += 1
|
|
110
|
+
if (seenUserTurns >= keepRecentTurns) return index
|
|
111
|
+
}
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function compactSummaryText(message) {
|
|
116
|
+
const content = message?.content
|
|
117
|
+
const text = typeof content === 'string'
|
|
118
|
+
? content
|
|
119
|
+
: Array.isArray(content)
|
|
120
|
+
? content.filter((block) => block?.type === 'text').map((block) => block.text || '').join('\n')
|
|
121
|
+
: ''
|
|
122
|
+
const match = text.match(/<compact_summary>\s*([\s\S]*?)\s*<\/compact_summary>/)
|
|
123
|
+
return match?.[1]?.trim() || text.trim()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function userTextMessage(text) {
|
|
127
|
+
return {
|
|
128
|
+
role: 'user',
|
|
129
|
+
content: [{ type: 'text', text }],
|
|
130
|
+
timestamp: Date.now(),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildCompactionSourceMessages(session, messages, tailStart) {
|
|
135
|
+
const source = []
|
|
136
|
+
const previousSummary = session.contextCompaction?.summaryMessage
|
|
137
|
+
if (previousSummary) {
|
|
138
|
+
source.push(userTextMessage([
|
|
139
|
+
'Existing rolling compact summary from earlier conversation history:',
|
|
140
|
+
'',
|
|
141
|
+
'<compact_summary>',
|
|
142
|
+
compactSummaryText(previousSummary),
|
|
143
|
+
'</compact_summary>',
|
|
144
|
+
].join('\n')))
|
|
145
|
+
}
|
|
146
|
+
source.push(...messages.slice(session.contextCompaction?.compactedUpToIndex || 0, tailStart))
|
|
147
|
+
return source
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function buildAutoCompactLoopMessages(session, messages) {
|
|
151
|
+
const summaryMessage = session?.contextCompaction?.summaryMessage
|
|
152
|
+
if (!summaryMessage) return messages
|
|
153
|
+
const keepRecentTurns = session.contextCompaction.keepRecentTurns || DEFAULT_AUTO_COMPACT_SETTINGS.keepRecentTurns
|
|
154
|
+
const tailStart = tailStartForRecentTurns(messages, keepRecentTurns)
|
|
155
|
+
return [summaryMessage, ...messages.slice(tailStart)]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function maybeAutoCompactSession({ session, messages, signal, emitSessionEvent, persistSession, logger, confirmAutoCompact }) {
|
|
159
|
+
if (!session || session.autoCompacting) return { compacted: false }
|
|
160
|
+
const settings = await readAutoCompactSettings()
|
|
161
|
+
if (!settings.enabled) return { compacted: false }
|
|
162
|
+
if (signal?.aborted) return { compacted: false }
|
|
163
|
+
|
|
164
|
+
const loopMessages = buildAutoCompactLoopMessages(session, messages)
|
|
165
|
+
const usage = estimateContextUsage({
|
|
166
|
+
systemPrompt: session.agent.state.systemPrompt,
|
|
167
|
+
messages: loopMessages,
|
|
168
|
+
tools: session.agent.state.tools,
|
|
169
|
+
model: session.model,
|
|
170
|
+
})
|
|
171
|
+
if (!usage.contextWindow || usage.percent < settings.thresholdPercent) return { compacted: false, usage }
|
|
172
|
+
|
|
173
|
+
emitSessionEvent?.(session, {
|
|
174
|
+
type: 'auto_compact_threshold_reached',
|
|
175
|
+
usage,
|
|
176
|
+
thresholdPercent: settings.thresholdPercent,
|
|
177
|
+
requireConfirmation: settings.requireConfirmation,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
if (settings.requireConfirmation) {
|
|
181
|
+
const approved = await confirmAutoCompact?.(session, { usage, settings })
|
|
182
|
+
if (!approved || signal?.aborted) return { compacted: false, usage, reason: approved === false ? 'user_rejected' : 'confirmation_unavailable' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const now = Date.now()
|
|
186
|
+
if (session.lastAutoCompactAt && now - session.lastAutoCompactAt < AUTO_COMPACT_MIN_INTERVAL_MS) {
|
|
187
|
+
return { compacted: false, usage, reason: 'recently_compacted' }
|
|
188
|
+
}
|
|
189
|
+
if (session.contextCompaction?.sourceMessageCount && messages.length <= session.contextCompaction.sourceMessageCount + 2) {
|
|
190
|
+
return { compacted: false, usage, reason: 'not_enough_new_messages' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const tailStart = tailStartForRecentTurns(messages, settings.keepRecentTurns)
|
|
194
|
+
const sourceMessages = buildCompactionSourceMessages(session, messages, tailStart)
|
|
195
|
+
if (sourceMessages.length < 2 || estimateMessagesChars(sourceMessages) < settings.minSourceChars) {
|
|
196
|
+
return { compacted: false, usage, reason: 'not_enough_history' }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
session.autoCompacting = true
|
|
200
|
+
try {
|
|
201
|
+
const result = await compactConversation({
|
|
202
|
+
messages: sourceMessages,
|
|
203
|
+
model: session.model,
|
|
204
|
+
thinkingLevel: session.thinkingLevel,
|
|
205
|
+
getApiKey: session.getApiKey,
|
|
206
|
+
keepTurns: 0,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
if (result.skipped) return { compacted: false, usage, reason: result.reason || 'skipped' }
|
|
210
|
+
|
|
211
|
+
await saveCompactBackup(session.sessionId, sourceMessages)
|
|
212
|
+
const summaryMessage = userTextMessage([
|
|
213
|
+
'The previous conversation has been automatically compacted. Treat the following summary as the authoritative replacement for earlier history. If information is missing, ask for clarification instead of guessing.',
|
|
214
|
+
'',
|
|
215
|
+
'<compact_summary>',
|
|
216
|
+
result.summary,
|
|
217
|
+
'</compact_summary>',
|
|
218
|
+
].join('\n'))
|
|
219
|
+
session.contextCompaction = {
|
|
220
|
+
summaryMessage,
|
|
221
|
+
compactedUpToIndex: tailStart,
|
|
222
|
+
compactedAt: new Date().toISOString(),
|
|
223
|
+
keepRecentTurns: settings.keepRecentTurns,
|
|
224
|
+
sourceMessageCount: messages.length,
|
|
225
|
+
usageBefore: usage,
|
|
226
|
+
thresholdPercent: settings.thresholdPercent,
|
|
227
|
+
}
|
|
228
|
+
session.lastAutoCompactAt = now
|
|
229
|
+
await persistSession(session)
|
|
230
|
+
emitSessionEvent(session, {
|
|
231
|
+
type: 'auto_compact_completed',
|
|
232
|
+
usage,
|
|
233
|
+
thresholdPercent: settings.thresholdPercent,
|
|
234
|
+
contextCompaction: session.contextCompaction,
|
|
235
|
+
})
|
|
236
|
+
emitSessionEvent(session, {
|
|
237
|
+
type: 'messages_replaced',
|
|
238
|
+
reason: 'auto_compact',
|
|
239
|
+
messages,
|
|
240
|
+
contextCompaction: session.contextCompaction,
|
|
241
|
+
})
|
|
242
|
+
return { compacted: true, usage }
|
|
243
|
+
} catch (error) {
|
|
244
|
+
logger?.warn?.(`Auto compact failed for session ${session.sessionId}:`, error?.message || error, { sessionId: session.sessionId })
|
|
245
|
+
return { compacted: false, usage, reason: 'error', error }
|
|
246
|
+
} finally {
|
|
247
|
+
session.autoCompacting = false
|
|
248
|
+
}
|
|
249
|
+
}
|
package/server/routes/agent.mjs
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
updateSessionThinkingLevel,
|
|
21
21
|
approveToolCall,
|
|
22
22
|
rejectToolCall,
|
|
23
|
+
approveAutoCompact,
|
|
24
|
+
rejectAutoCompact,
|
|
25
|
+
abortToolCall,
|
|
23
26
|
replaceSessionMessages,
|
|
24
27
|
rollbackSessionMessages,
|
|
25
28
|
agentEvents,
|
|
@@ -225,6 +228,30 @@ export async function handleAgentApi(req, res, url) {
|
|
|
225
228
|
return
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// POST /api/agents/:sessionId/approve-auto-compact — approve a pending automatic context compaction
|
|
232
|
+
if (req.method === 'POST' && subPath === 'approve-auto-compact') {
|
|
233
|
+
const body = await readJsonBody(req)
|
|
234
|
+
const result = approveAutoCompact(sessionId, body?.approvalId)
|
|
235
|
+
sendJson(res, 200, result)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// POST /api/agents/:sessionId/reject-auto-compact — skip a pending automatic context compaction
|
|
240
|
+
if (req.method === 'POST' && subPath === 'reject-auto-compact') {
|
|
241
|
+
const body = await readJsonBody(req)
|
|
242
|
+
const result = rejectAutoCompact(sessionId, body?.approvalId)
|
|
243
|
+
sendJson(res, 200, result)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// POST /api/agents/:sessionId/abort-tool — abort a running run_command tool call
|
|
248
|
+
if (req.method === 'POST' && subPath === 'abort-tool') {
|
|
249
|
+
const body = await readJsonBody(req)
|
|
250
|
+
const result = abortToolCall(sessionId, body?.toolCallId)
|
|
251
|
+
sendJson(res, 200, result)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
228
255
|
const error = new Error('Not found')
|
|
229
256
|
error.statusCode = 404
|
|
230
257
|
throw error
|
|
@@ -68,7 +68,7 @@ export const workspaceTools = [
|
|
|
68
68
|
description: 'Run a shell command in the project bound to this chat. Use this for lint, build, tests, git status, and diagnostics.',
|
|
69
69
|
parameters: Type.Object({
|
|
70
70
|
command: Type.String({ description: 'Command to execute in the workspace.' }),
|
|
71
|
-
timeoutSeconds: Type.Optional(Type.Number({ description: 'Timeout in seconds. Defaults to
|
|
71
|
+
timeoutSeconds: Type.Optional(Type.Number({ description: 'Timeout in seconds. Defaults to 600 (10 minutes).', default: 600 })),
|
|
72
72
|
}),
|
|
73
73
|
executionMode: 'sequential',
|
|
74
74
|
},
|