@electerm/electerm-react 5.5.35 → 5.5.66
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/client/common/bookmark-schemas.js +1 -0
- package/client/common/constants.js +1 -0
- package/client/common/dangerous-session-fields.js +39 -0
- package/client/common/parse-quick-connect.js +47 -6
- package/client/common/parse-widget-log.js +57 -0
- package/client/common/safe-join.js +33 -0
- package/client/common/sanitize-filename.js +8 -0
- package/client/components/ai/agent-tool-call-card.jsx +7 -3
- package/client/components/ai/agent.js +176 -33
- package/client/components/ai/ai-auto-compress.js +119 -0
- package/client/components/ai/ai-chat-history-item.jsx +125 -40
- package/client/components/ai/ai-chat.jsx +67 -19
- package/client/components/ai/ai-compress.js +95 -0
- package/client/components/ai/ai-config.jsx +29 -16
- package/client/components/ai/ai-config.styl +20 -0
- package/client/components/ai/ai-context.js +1 -1
- package/client/components/ai/ai-output.jsx +1 -6
- package/client/components/ai/ai-presets.js +0 -15
- package/client/components/batch-op/batch-op-runner.jsx +1 -0
- package/client/components/bookmark-form/bookmark-schema.js +1 -0
- package/client/components/bookmark-form/common/ssh-agent.jsx +31 -21
- package/client/components/bookmark-form/config/ssh.js +1 -0
- package/client/components/bookmark-form/fix-bookmark-default.js +1 -0
- package/client/components/file-transfer/remote2remote-handlers.jsx +8 -3
- package/client/components/file-transfer/transfer.jsx +3 -119
- package/client/components/main/main.jsx +1 -0
- package/client/components/session/session-control.jsx +35 -2
- package/client/components/session/session-control.styl +5 -0
- package/client/components/session/session.jsx +13 -0
- package/client/components/setting-panel/setting-terminal.jsx +1 -0
- package/client/components/setting-panel/tab-widgets.jsx +27 -7
- package/client/components/sftp/file-item.jsx +0 -13
- package/client/components/terminal/mixins/term-init.js +1 -0
- package/client/components/terminal/mixins/term-touch.js +18 -0
- package/client/components/terminal/shell.js +11 -3
- package/client/components/terminal/shortcut-bar-press.js +192 -0
- package/client/components/terminal/shortcut-bar.jsx +199 -4
- package/client/components/terminal/shortcut-bar.styl +40 -21
- package/client/components/terminal/terminal.jsx +4 -0
- package/client/components/widgets/widget-control.jsx +4 -2
- package/client/components/widgets/widget-instance-detail.jsx +370 -0
- package/client/components/widgets/widget-instance.jsx +30 -13
- package/client/components/widgets/widget-instances.jsx +3 -1
- package/client/components/widgets/widgets-list.jsx +8 -0
- package/client/components/widgets/widgets.styl +100 -0
- package/client/store/common.js +81 -23
- package/client/store/init-state.js +7 -0
- package/client/store/load-data.js +1 -0
- package/client/store/mcp-handler.js +8 -15
- package/client/store/sync.js +1 -0
- package/client/store/tab.js +2 -15
- package/client/store/widgets.js +23 -5
- package/package.json +1 -1
- package/client/components/file-transfer/zip.js +0 -42
|
@@ -62,6 +62,7 @@ export const sshBookmarkSchema = {
|
|
|
62
62
|
enableSftp: z.boolean().optional().describe('Enable sftp, default is true'),
|
|
63
63
|
useSshAgent: z.boolean().optional().describe('Use SSH agent, default is true'),
|
|
64
64
|
sshAgent: z.string().optional().describe('SSH agent path'),
|
|
65
|
+
agentForward: z.boolean().optional().describe('Enable SSH agent forwarding (like ssh -A), requires SSH agent, default is false'),
|
|
65
66
|
serverHostKey: z.array(z.string()).optional().describe('Server host key algorithms'),
|
|
66
67
|
cipher: z.array(z.string()).optional().describe('Cipher list'),
|
|
67
68
|
compress: z.array(z.string()).optional().describe('Compression algorithms'),
|
|
@@ -302,6 +302,7 @@ export const regexHelpLink = 'https://github.com/electerm/electerm/wiki/Terminal
|
|
|
302
302
|
export const connectionHoppingWikiLink = 'https://github.com/electerm/electerm/wiki/Connection-Hopping-Behavior-Change-in-electerm-since-v1.50.65'
|
|
303
303
|
export const aiConfigWikiLink = 'https://github.com/electerm/electerm/wiki/AI-model-config-guide'
|
|
304
304
|
export const aiChatModeLsKey = 'ai-chat-mode'
|
|
305
|
+
export const aiAutoCompressLsKey = 'ai-auto-compress'
|
|
305
306
|
export const lastAiChatSessionIdKey = 'last-ai-chat-session-id'
|
|
306
307
|
export const aiTermOfUseConfirmedLsKey = 'ai-term-of-use-confirmed'
|
|
307
308
|
export const syncTermOfUseConfirmedLsKey = 'sync-term-of-use-confirmed'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session fields that must never be settable from untrusted input: a deep link
|
|
3
|
+
* URL, CLI `--opts`, an MCP/AI tool call, or a shortcut file.
|
|
4
|
+
*
|
|
5
|
+
* Every entry reaches one of: child_process.spawn(), a shell, the process
|
|
6
|
+
* environment, or the terminal input stream.
|
|
7
|
+
*
|
|
8
|
+
* Why this is a shared module and not a local array: `proxyCommand` was added
|
|
9
|
+
* by a later feature and reached spawn() from a single clicked link because the
|
|
10
|
+
* denylists of the time were five separate copies and none of them was updated
|
|
11
|
+
* (follow-up to GHSA-mpm8-cx2p-626q). One list, imported everywhere, is what
|
|
12
|
+
* makes the guard maintainable.
|
|
13
|
+
*
|
|
14
|
+
* Why a denylist and not an allowlist: a quick connect string may legitimately
|
|
15
|
+
* carry any session option (encode, term, authType, agentForward, sshTunnels,
|
|
16
|
+
* startDirectoryRemote, ...). An allowlist would silently break every field it
|
|
17
|
+
* does not know about, which is a worse failure than the one it prevents. The
|
|
18
|
+
* cost of that choice is that a future exec-capable option must be added here.
|
|
19
|
+
*
|
|
20
|
+
* Keep in sync with the copy of this file in the other bundle - there is a unit
|
|
21
|
+
* test that fails when the two copies drift.
|
|
22
|
+
*/
|
|
23
|
+
export default [
|
|
24
|
+
// spawns an external process (before any SSH authentication)
|
|
25
|
+
'proxyCommand',
|
|
26
|
+
// run scripts after connect / override the shell binary
|
|
27
|
+
'runScripts',
|
|
28
|
+
'execLinux',
|
|
29
|
+
'execMac',
|
|
30
|
+
'execWindows',
|
|
31
|
+
'execLinuxArgs',
|
|
32
|
+
'execMacArgs',
|
|
33
|
+
'execWindowsArgs',
|
|
34
|
+
// injects environment variables into the session
|
|
35
|
+
'setEnv',
|
|
36
|
+
// injects interactive prompts / auto-answers terminal output
|
|
37
|
+
'interactiveValues',
|
|
38
|
+
'triggers'
|
|
39
|
+
]
|
|
@@ -19,13 +19,38 @@
|
|
|
19
19
|
* 192.168.1.100:22
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import DANGEROUS_SESSION_FIELDS from './dangerous-session-fields'
|
|
23
|
+
|
|
22
24
|
const SUPPORTED_PROTOCOLS = ['ssh', 'telnet', 'vnc', 'rdp', 'spice', 'serial', 'ftp', 'http', 'https', 'electerm']
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
* Deny list for opts keys
|
|
26
|
-
*
|
|
27
|
+
* Deny list for opts keys.
|
|
28
|
+
*
|
|
29
|
+
* `type` and `host` are parsed from the URL itself, so `opts` must not be able
|
|
30
|
+
* to override them. Everything else is a field that reaches
|
|
31
|
+
* child_process.spawn(), a shell, the environment or the terminal input stream
|
|
32
|
+
* - see common/dangerous-session-fields.js.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately a deny list rather than an allow list: a quick connect string
|
|
35
|
+
* may legitimately carry any session option (encode, term, authType,
|
|
36
|
+
* agentForward, sshTunnels, ...), so an allow list would silently break every
|
|
37
|
+
* field it does not know about.
|
|
27
38
|
*/
|
|
28
|
-
const OPTS_DENY_LIST = ['type', 'host']
|
|
39
|
+
const OPTS_DENY_LIST = ['type', 'host', ...DANGEROUS_SESSION_FIELDS]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Return a copy of an untrusted object without the denied keys.
|
|
43
|
+
* @param {object} obj - untrusted object
|
|
44
|
+
* @param {string[]} denyList - keys to drop
|
|
45
|
+
* @returns {object} a new object with the denied keys removed
|
|
46
|
+
*/
|
|
47
|
+
function withoutDenied (obj, denyList = OPTS_DENY_LIST) {
|
|
48
|
+
const out = { ...(obj || {}) }
|
|
49
|
+
denyList.forEach(key => {
|
|
50
|
+
delete out[key]
|
|
51
|
+
})
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
29
54
|
|
|
30
55
|
/**
|
|
31
56
|
* Default ports for each protocol
|
|
@@ -53,6 +78,7 @@ const TYPE_DEFAULT_VALUES = {
|
|
|
53
78
|
enableSsh: true,
|
|
54
79
|
enableSftp: true,
|
|
55
80
|
useSshAgent: true,
|
|
81
|
+
agentForward: false,
|
|
56
82
|
authType: 'password',
|
|
57
83
|
term: 'xterm-256color',
|
|
58
84
|
encode: 'utf-8',
|
|
@@ -403,8 +429,22 @@ function parseQuickConnect (str) {
|
|
|
403
429
|
if (optsStr) {
|
|
404
430
|
try {
|
|
405
431
|
const extraOpts = JSON.parse(optsStr)
|
|
406
|
-
|
|
407
|
-
|
|
432
|
+
if (
|
|
433
|
+
extraOpts &&
|
|
434
|
+
typeof extraOpts === 'object' &&
|
|
435
|
+
!Array.isArray(extraOpts)
|
|
436
|
+
) {
|
|
437
|
+
Object.assign(opts, withoutDenied(extraOpts))
|
|
438
|
+
}
|
|
439
|
+
// a hop entry is spread into a sub-session (server/session-hop.js),
|
|
440
|
+
// so it is its own untrusted surface and needs the same filtering.
|
|
441
|
+
// Only the dangerous fields apply here - unlike the top level, a hop
|
|
442
|
+
// legitimately sets its own host and port.
|
|
443
|
+
if (Array.isArray(opts.connectionHoppings)) {
|
|
444
|
+
opts.connectionHoppings = opts.connectionHoppings
|
|
445
|
+
.filter(hop => hop && typeof hop === 'object' && !Array.isArray(hop))
|
|
446
|
+
.map(hop => withoutDenied(hop, DANGEROUS_SESSION_FIELDS))
|
|
447
|
+
}
|
|
408
448
|
} catch (err) {
|
|
409
449
|
console.error('Failed to parse opts:', err)
|
|
410
450
|
}
|
|
@@ -451,5 +491,6 @@ export {
|
|
|
451
491
|
getSupportedProtocols,
|
|
452
492
|
SUPPORTED_PROTOCOLS,
|
|
453
493
|
DEFAULT_PORTS,
|
|
454
|
-
OPTS_DENY_LIST
|
|
494
|
+
OPTS_DENY_LIST,
|
|
495
|
+
DANGEROUS_SESSION_FIELDS
|
|
455
496
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a widget log file into the two streams the widget panel shows.
|
|
3
|
+
*
|
|
4
|
+
* The writer is src/app/widgets/instance-log.js; the format is one entry per
|
|
5
|
+
* line, pipe separated:
|
|
6
|
+
* <iso time>|log|<level>|<message>
|
|
7
|
+
* <iso time>|conn|<ok|fail|info>|<type>|<from>|<message>
|
|
8
|
+
* A message may contain pipes, so everything after the last known separator is
|
|
9
|
+
* taken as the message.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CONN_STATE = {
|
|
13
|
+
ok: true,
|
|
14
|
+
fail: false,
|
|
15
|
+
info: null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseWidgetLog (text) {
|
|
19
|
+
const logs = []
|
|
20
|
+
const events = []
|
|
21
|
+
for (const line of String(text || '').split('\n')) {
|
|
22
|
+
if (!line) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
const parts = line.split('|')
|
|
26
|
+
if (parts.length < 4) {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
const ts = Date.parse(parts[0])
|
|
30
|
+
if (!ts) {
|
|
31
|
+
continue
|
|
32
|
+
}
|
|
33
|
+
if (parts[1] === 'conn') {
|
|
34
|
+
if (parts.length < 6) {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
events.push({
|
|
38
|
+
ts,
|
|
39
|
+
ok: Object.prototype.hasOwnProperty.call(CONN_STATE, parts[2])
|
|
40
|
+
? CONN_STATE[parts[2]]
|
|
41
|
+
: null,
|
|
42
|
+
type: parts[3],
|
|
43
|
+
from: parts[4] === '-' ? '' : parts[4],
|
|
44
|
+
msg: parts.slice(5).join('|')
|
|
45
|
+
})
|
|
46
|
+
} else {
|
|
47
|
+
logs.push({
|
|
48
|
+
ts,
|
|
49
|
+
level: parts[2],
|
|
50
|
+
msg: parts.slice(3).join('|')
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { logs, events }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default parseWidgetLog
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join a base directory with a filename that may come from an untrusted source
|
|
3
|
+
* (a remote server's directory listing, a drag payload, ...) and keep the
|
|
4
|
+
* result inside the base.
|
|
5
|
+
*
|
|
6
|
+
* The sanitize step is load-bearing, not cosmetic. electerm's `resolve` does no
|
|
7
|
+
* normalisation and three of its branches discard the base outright: a leading
|
|
8
|
+
* `/`, a UNC `\\`, and a Windows drive prefix. The drive branch is the easy one
|
|
9
|
+
* to miss because it needs no separator at all — `resolve('/uploads', 'C:x')`
|
|
10
|
+
* returns `'C:x'`. Stripping `:` and the separators removes every one of those
|
|
11
|
+
* branches, leaving only the "append to base" path.
|
|
12
|
+
*
|
|
13
|
+
* @param {String} base directory the name must stay inside
|
|
14
|
+
* @param {String} name untrusted file name
|
|
15
|
+
* @return {String} base + separator + a safe name, always inside `base`
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import resolve from './resolve.js'
|
|
19
|
+
import sanitizeFilename from './sanitize-filename.js'
|
|
20
|
+
|
|
21
|
+
// A single path component: no separator, and not `.` or `..`.
|
|
22
|
+
const SINGLE_COMPONENT = /^(?!\.\.?$)[^/\\]+$/
|
|
23
|
+
|
|
24
|
+
export default function safeJoin (base, name) {
|
|
25
|
+
const safeName = sanitizeFilename(name)
|
|
26
|
+
// sanitizeFilename already satisfies this, so the fallback is unreachable in
|
|
27
|
+
// practice: it is here so that a future change to its character list degrades
|
|
28
|
+
// to a neutral name instead of escaping the base. Deliberately not a throw —
|
|
29
|
+
// a bad entry in a remote listing must not take down the whole drop. It also
|
|
30
|
+
// stays silent on purpose: no legitimate name contains a separator, so the
|
|
31
|
+
// only names that can reach the fallback are hostile ones.
|
|
32
|
+
return resolve(base, SINGLE_COMPONENT.test(safeName) ? safeName : 'unnamed')
|
|
33
|
+
}
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* - Reject reserved Windows device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9
|
|
15
15
|
* - Limit filename length to 255 bytes (common filesystem limit)
|
|
16
16
|
* - Fallback to 'unnamed' if result is empty
|
|
17
|
+
*
|
|
18
|
+
* Note for callers using this to build a path: the output is guaranteed to
|
|
19
|
+
* contain no path separator (`/` or `\`) and never to be `.` or `..`, because
|
|
20
|
+
* all of those are in ILLEGAL_CHARS or stripped as trailing dots. That is what
|
|
21
|
+
* makes `resolve(dir, sanitizeFilename(name))` stay inside `dir` — and it is
|
|
22
|
+
* load-bearing for path containment (CWE-22), not just cosmetics. Prefer
|
|
23
|
+
* `safeJoin(base, name)` when the name is untrusted: it depends on this
|
|
24
|
+
* property and asserts it.
|
|
17
25
|
*/
|
|
18
26
|
|
|
19
27
|
// Characters illegal on Windows (and problematic on many systems)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
1
|
+
import { useState, useEffect, memo } from 'react'
|
|
2
2
|
import { Tag } from 'antd'
|
|
3
3
|
import {
|
|
4
4
|
CaretDownOutlined,
|
|
@@ -34,7 +34,11 @@ function formatResult (result) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// memo()'d on the entry object: `formatResult` JSON.parses the whole tool
|
|
38
|
+
// result on every render, and an agent turn can hold dozens of these. The
|
|
39
|
+
// agent loop swaps in a new entry object when a call finishes
|
|
40
|
+
// (runAgentLoop), so an unchanged card keeps its identity and is skipped.
|
|
41
|
+
export default memo(function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
38
42
|
const [expanded, setExpanded] = useState(toolCall.status === 'running')
|
|
39
43
|
const { name, args, status, result } = toolCall
|
|
40
44
|
const Icon = toolIcons[name] || CodeOutlined
|
|
@@ -110,4 +114,4 @@ export default function AgentToolCallCard ({ toolCall, autoCollapse }) {
|
|
|
110
114
|
)}
|
|
111
115
|
</div>
|
|
112
116
|
)
|
|
113
|
-
}
|
|
117
|
+
})
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { agentTools, executeToolCall } from './agent-tools'
|
|
2
2
|
import { appendMandatoryGuardrails } from './ai-guardrails'
|
|
3
|
-
import { buildAgentMessages, summarizeContext } from './ai-context'
|
|
3
|
+
import { buildAgentMessages, summarizeContext, CONTEXT_DANGER_PERCENT } from './ai-context'
|
|
4
|
+
import {
|
|
5
|
+
shouldAutoCompress,
|
|
6
|
+
canCompact,
|
|
7
|
+
applySummary
|
|
8
|
+
} from './ai-auto-compress'
|
|
9
|
+
import {
|
|
10
|
+
autoCompressEnabled,
|
|
11
|
+
summarizeMessages,
|
|
12
|
+
autoCompressSession
|
|
13
|
+
} from './ai-compress'
|
|
14
|
+
import uid from '../../common/uid'
|
|
4
15
|
|
|
5
16
|
const MAX_ITERATIONS = 150
|
|
6
17
|
|
|
18
|
+
// A summary request that failed (provider hiccup, rate limit) must not be
|
|
19
|
+
// retried on every following iteration -- that would turn a full window into a
|
|
20
|
+
// call per tool result. Only try again once the estimate has grown by this
|
|
21
|
+
// share of the window.
|
|
22
|
+
const AUTO_COMPRESS_RETRY_GROWTH_PERCENT = 5
|
|
23
|
+
|
|
24
|
+
// Which loop currently owns `store.agentRunning`. A stopped run keeps
|
|
25
|
+
// unwinding for a moment (its last request has to settle), and it must not
|
|
26
|
+
// clear the flag of a run the user started in the meantime.
|
|
27
|
+
let activeRunId = null
|
|
28
|
+
|
|
7
29
|
function buildAgentSystemPrompt (config) {
|
|
8
30
|
const lang = config.languageAI || window.store.getLangName()
|
|
9
31
|
const baseRole = config.roleAI || 'You are a helpful assistant.'
|
|
@@ -27,14 +49,10 @@ Reply in ${lang} language.`)
|
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
function updateChatEntry (chatEntry, updates) {
|
|
30
|
-
|
|
31
|
-
if (index !== -1) {
|
|
32
|
-
Object.assign(window.store.aiChatHistory[index], updates)
|
|
33
|
-
window.store.aiChatHistory = [...window.store.aiChatHistory]
|
|
34
|
-
}
|
|
52
|
+
window.store.updateAiHistoryEntry(chatEntry.id, updates)
|
|
35
53
|
}
|
|
36
54
|
|
|
37
|
-
async function callBackendAIchatWithTools (messages, config) {
|
|
55
|
+
async function callBackendAIchatWithTools (messages, config, requestId) {
|
|
38
56
|
return window.pre.runGlobalAsync(
|
|
39
57
|
'AIchatWithTools',
|
|
40
58
|
messages,
|
|
@@ -44,7 +62,11 @@ async function callBackendAIchatWithTools (messages, config) {
|
|
|
44
62
|
config.apiKeyAI,
|
|
45
63
|
config.proxyAI,
|
|
46
64
|
agentTools,
|
|
47
|
-
config.authHeaderNameAI
|
|
65
|
+
config.authHeaderNameAI,
|
|
66
|
+
// `format` stays undefined (the main process detects it); `requestId` is
|
|
67
|
+
// the last argument so that exact request can be cancelled on stop.
|
|
68
|
+
undefined,
|
|
69
|
+
requestId
|
|
48
70
|
)
|
|
49
71
|
}
|
|
50
72
|
|
|
@@ -67,7 +89,16 @@ function publishContext (messages, config, usage) {
|
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming, conversationMessages = null) {
|
|
92
|
+
const runId = uid()
|
|
93
|
+
activeRunId = runId
|
|
70
94
|
window.store.agentRunning = true
|
|
95
|
+
const isAborted = () => !!(abortRef && abortRef.current)
|
|
96
|
+
// Auto compression bookkeeping, read by the finally block as well: how many
|
|
97
|
+
// times this run compacted itself, whether it failed on the way, and the
|
|
98
|
+
// size at the last summary attempt (AUTO_COMPRESS_RETRY_GROWTH_PERCENT)
|
|
99
|
+
let autoCompressCount = 0
|
|
100
|
+
let autoCompressAttemptTokens = 0
|
|
101
|
+
let runErrored = false
|
|
71
102
|
try {
|
|
72
103
|
const messages = buildAgentMessages({
|
|
73
104
|
systemPrompt: buildAgentSystemPrompt(config),
|
|
@@ -78,6 +109,13 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
78
109
|
let accumulatedContent = ''
|
|
79
110
|
let lastUsage = null
|
|
80
111
|
|
|
112
|
+
const markStopped = () => {
|
|
113
|
+
setIsStreaming(false)
|
|
114
|
+
updateChatEntry(chatEntry, {
|
|
115
|
+
response: accumulatedContent + '\n\n*(Agent stopped by user)*'
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
81
119
|
setIsStreaming(true)
|
|
82
120
|
updateChatEntry(chatEntry, {
|
|
83
121
|
toolCalls: [],
|
|
@@ -85,21 +123,79 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
85
123
|
})
|
|
86
124
|
|
|
87
125
|
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
updateChatEntry(chatEntry, {
|
|
91
|
-
response: accumulatedContent + '\n\n*(Agent stopped by user)*'
|
|
92
|
-
})
|
|
126
|
+
if (isAborted()) {
|
|
127
|
+
markStopped()
|
|
93
128
|
return
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
publishContext(messages, config, lastUsage)
|
|
97
|
-
|
|
132
|
+
|
|
133
|
+
// Auto compression, when the popover toggle is on: the request about to
|
|
134
|
+
// go out carries the whole conversation plus a tool result per step, and
|
|
135
|
+
// past ~90% of the window the provider rejects it outright. Replacing the
|
|
136
|
+
// conversation with a summary first keeps the run alive; the stored
|
|
137
|
+
// session is compacted at the end (autoCompressSession), because that is
|
|
138
|
+
// what the next turn starts from.
|
|
139
|
+
const info = window.store.aiContextInfo
|
|
140
|
+
// A failed summary is only worth retrying once the context has grown by
|
|
141
|
+
// this much -- but never more than the trigger itself, or a threshold
|
|
142
|
+
// below it would let the retry floor decide when to compress.
|
|
143
|
+
const growthPercent = Math.min(
|
|
144
|
+
AUTO_COMPRESS_RETRY_GROWTH_PERCENT,
|
|
145
|
+
CONTEXT_DANGER_PERCENT
|
|
146
|
+
)
|
|
147
|
+
const grewEnough = info &&
|
|
148
|
+
info.tokens - autoCompressAttemptTokens >=
|
|
149
|
+
(info.windowSize || 0) * growthPercent / 100
|
|
150
|
+
if (
|
|
151
|
+
autoCompressEnabled() &&
|
|
152
|
+
canCompact(messages) &&
|
|
153
|
+
shouldAutoCompress(info) &&
|
|
154
|
+
grewEnough
|
|
155
|
+
) {
|
|
156
|
+
autoCompressAttemptTokens = info.tokens
|
|
157
|
+
// Shown before the request rather than after: summarizing a nearly full
|
|
158
|
+
// window takes a while, and this badge is the only sign of why the run
|
|
159
|
+
// is waiting. Taken back if the summary comes back unusable.
|
|
160
|
+
updateChatEntry(chatEntry, { autoCompressCount: autoCompressCount + 1 })
|
|
161
|
+
const summary = await summarizeMessages(messages, config)
|
|
162
|
+
if (isAborted()) {
|
|
163
|
+
markStopped()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (summary) {
|
|
167
|
+
applySummary(messages, summary)
|
|
168
|
+
autoCompressCount++
|
|
169
|
+
lastUsage = null
|
|
170
|
+
publishContext(messages, config, null)
|
|
171
|
+
} else {
|
|
172
|
+
updateChatEntry(chatEntry, { autoCompressCount })
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const requestId = uid()
|
|
177
|
+
if (abortRef) {
|
|
178
|
+
abortRef.requestId = requestId
|
|
179
|
+
}
|
|
180
|
+
const result = await callBackendAIchatWithTools(messages, config, requestId)
|
|
181
|
+
if (abortRef && abortRef.requestId === requestId) {
|
|
182
|
+
abortRef.requestId = null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Re-check after the await, before anything from the answer is used:
|
|
186
|
+
// stopping cancels the request, and neither the partial answer nor the
|
|
187
|
+
// resulting error belongs in the transcript.
|
|
188
|
+
if (isAborted()) {
|
|
189
|
+
markStopped()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
98
193
|
if (result.usage) {
|
|
99
194
|
lastUsage = result.usage
|
|
100
195
|
}
|
|
101
196
|
|
|
102
197
|
if (result.error) {
|
|
198
|
+
runErrored = true
|
|
103
199
|
setIsStreaming(false)
|
|
104
200
|
updateChatEntry(chatEntry, {
|
|
105
201
|
response: accumulatedContent + `\n\n**Error:** ${result.error}`
|
|
@@ -109,6 +205,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
109
205
|
|
|
110
206
|
const assistantMessage = result.message
|
|
111
207
|
if (!assistantMessage) {
|
|
208
|
+
runErrored = true
|
|
112
209
|
setIsStreaming(false)
|
|
113
210
|
updateChatEntry(chatEntry, {
|
|
114
211
|
response: accumulatedContent || 'No response from AI.'
|
|
@@ -135,11 +232,8 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
135
232
|
}
|
|
136
233
|
|
|
137
234
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
updateChatEntry(chatEntry, {
|
|
141
|
-
response: accumulatedContent + '\n\n*(Agent stopped by user)*'
|
|
142
|
-
})
|
|
235
|
+
if (isAborted()) {
|
|
236
|
+
markStopped()
|
|
143
237
|
return
|
|
144
238
|
}
|
|
145
239
|
|
|
@@ -150,28 +244,32 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
150
244
|
args = {}
|
|
151
245
|
}
|
|
152
246
|
|
|
153
|
-
|
|
247
|
+
toolCallsLog.push({
|
|
154
248
|
id: toolCall.id,
|
|
155
249
|
name: toolCall.function.name,
|
|
156
250
|
args,
|
|
157
251
|
status: 'running',
|
|
158
252
|
result: null
|
|
159
|
-
}
|
|
160
|
-
toolCallsLog.push(toolEntry)
|
|
253
|
+
})
|
|
161
254
|
updateChatEntry(chatEntry, {
|
|
162
255
|
toolCalls: [...toolCallsLog]
|
|
163
256
|
})
|
|
164
257
|
|
|
165
|
-
let
|
|
258
|
+
let finished
|
|
166
259
|
try {
|
|
167
|
-
toolResult = await executeToolCall(toolCall.function.name, args)
|
|
168
|
-
|
|
169
|
-
toolEntry.result = toolResult
|
|
260
|
+
const toolResult = await executeToolCall(toolCall.function.name, args)
|
|
261
|
+
finished = { status: 'completed', result: toolResult }
|
|
170
262
|
} catch (err) {
|
|
171
|
-
|
|
172
|
-
toolEntry.result = err.message
|
|
263
|
+
finished = { status: 'error', result: err.message }
|
|
173
264
|
}
|
|
174
265
|
|
|
266
|
+
// Swap in a new entry object instead of mutating the running one: the
|
|
267
|
+
// tool call card is memo()'d on that object's identity, so an in-place
|
|
268
|
+
// mutation would leave the card showing "running" forever.
|
|
269
|
+
toolCallsLog[toolCallsLog.length - 1] = {
|
|
270
|
+
...toolCallsLog[toolCallsLog.length - 1],
|
|
271
|
+
...finished
|
|
272
|
+
}
|
|
175
273
|
updateChatEntry(chatEntry, {
|
|
176
274
|
toolCalls: [...toolCallsLog]
|
|
177
275
|
})
|
|
@@ -179,7 +277,7 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
179
277
|
messages.push({
|
|
180
278
|
role: 'tool',
|
|
181
279
|
tool_call_id: toolCall.id,
|
|
182
|
-
content:
|
|
280
|
+
content: finished.result
|
|
183
281
|
})
|
|
184
282
|
}
|
|
185
283
|
}
|
|
@@ -189,9 +287,54 @@ export async function runAgentLoop (chatEntry, config, abortRef, setIsStreaming,
|
|
|
189
287
|
response: accumulatedContent + '\n\n*(Agent reached maximum iterations)*'
|
|
190
288
|
})
|
|
191
289
|
} finally {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
290
|
+
if (abortRef) {
|
|
291
|
+
abortRef.requestId = null
|
|
292
|
+
}
|
|
293
|
+
// Only the run that still owns the flag may clear it -- a stopped run
|
|
294
|
+
// unwinds a moment later and must not unlock a newer one.
|
|
295
|
+
const ownsRun = activeRunId === runId
|
|
296
|
+
if (ownsRun) {
|
|
297
|
+
activeRunId = null
|
|
298
|
+
window.store.agentRunning = false
|
|
299
|
+
// hand the panel back to the session based estimate: tool results are not
|
|
300
|
+
// carried into the next turn, so the agent figure stops being meaningful
|
|
301
|
+
window.store.aiContextInfo = null
|
|
302
|
+
}
|
|
303
|
+
// A run that compacted its live conversation leaves the stored session
|
|
304
|
+
// untouched, so the next turn would start from the full history again and
|
|
305
|
+
// pay for another summary. Compact the session as well -- once, after the
|
|
306
|
+
// run, when the turns it summarizes are complete. Skipped for a stopped or
|
|
307
|
+
// failed run: those transcripts are not a conversation worth keeping.
|
|
308
|
+
if (ownsRun && autoCompressCount > 0 && !runErrored && !isAborted()) {
|
|
309
|
+
await autoCompressSession(chatEntry.chatSessionId, config)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Stop the running agent loop, called by the panel's stop button.
|
|
315
|
+
//
|
|
316
|
+
// The loop itself only looks at `abortRef` between iterations, so on its own
|
|
317
|
+
// that flag does nothing while a request is in flight -- and the panel keeps
|
|
318
|
+
// the composer locked for as long as the provider takes to answer (which is
|
|
319
|
+
// forever if it never does, these requests carry no timeout). So this also
|
|
320
|
+
// cancels the in-flight request and releases the composer right away; the loop
|
|
321
|
+
// then unwinds on its own and marks the entry as stopped.
|
|
322
|
+
export async function stopAgentRun (abortRef) {
|
|
323
|
+
if (abortRef) {
|
|
324
|
+
abortRef.current = true
|
|
325
|
+
}
|
|
326
|
+
const requestId = abortRef && abortRef.requestId
|
|
327
|
+
if (abortRef) {
|
|
328
|
+
abortRef.requestId = null
|
|
329
|
+
}
|
|
330
|
+
activeRunId = null
|
|
331
|
+
window.store.agentRunning = false
|
|
332
|
+
window.store.aiContextInfo = null
|
|
333
|
+
if (requestId) {
|
|
334
|
+
try {
|
|
335
|
+
await window.pre.runGlobalAsync('abortAIRequest', requestId)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error('Error aborting agent request:', error)
|
|
338
|
+
}
|
|
196
339
|
}
|
|
197
340
|
}
|