@steerable/agent-shell 0.6.56 → 0.6.57
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/dist/cli-install.d.ts +27 -0
- package/dist/cli-install.js +105 -0
- package/dist/goal-store.js +16 -3
- package/dist/host/http-routes.d.ts +8 -0
- package/dist/host/http-routes.js +18 -0
- package/dist/host/pack-cli.d.ts +19 -0
- package/dist/host/pack-cli.js +56 -0
- package/dist/host/runtime.js +24 -4
- package/dist/insights/flush.js +1 -1
- package/dist/json-store.js +17 -1
- package/dist/local-backend/router.d.ts +3 -0
- package/dist/local-backend/router.js +325 -249
- package/dist/local-backend/skill-install.js +11 -2
- package/dist/log.d.ts +13 -0
- package/dist/log.js +21 -0
- package/dist/scenario/pack.d.ts +1 -1
- package/dist/sidecar/reverse-spawn.js +5 -1
- package/dist/storage/driver.d.ts +4 -0
- package/dist/storage/driver.js +4 -0
- package/dist/storage/index.d.ts +8 -3
- package/dist/storage/index.js +59 -20
- package/dist/storage/process-locks.d.ts +32 -0
- package/dist/storage/process-locks.js +99 -0
- package/dist/storage/scoped-store.d.ts +1 -0
- package/dist/storage/sqlite-driver.d.ts +3 -1
- package/dist/storage/sqlite-driver.js +89 -8
- package/dist/storage/write-lease.d.ts +7 -1
- package/dist/storage/write-lease.js +35 -4
- package/package.json +4 -4
|
@@ -3,7 +3,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
5
|
import os from 'node:os';
|
|
6
|
-
import { getAppRootDir, shellOpenPath } from '../runtime.js';
|
|
6
|
+
import { getAppRootDir, getUserDataDir, shellOpenPath } from '../runtime.js';
|
|
7
|
+
import { acquireChatWriteLock, ChatBusyError, } from '../storage/process-locks.js';
|
|
7
8
|
import { llmService, getSidecarSupervisor, whenSidecarSupervisor } from '../llm/index.js';
|
|
8
9
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
import { buildWorldState, getActiveCoreLoopStreamId, streamCoreLoopTurn, } from './coreloop-stream.js';
|
|
@@ -48,6 +49,8 @@ import { allocateProjectHome, ensureChatWorkspace, ensureProjectHome, expandUser
|
|
|
48
49
|
import { registerLiveStream, getLiveStream, removeLiveStream } from './live-stream.js';
|
|
49
50
|
import { beginPackTurnObservers, collectPackExecWritableRoots, collectPackForcedSkillVars, collectPackWorldState, } from './pack-turn-hooks.js';
|
|
50
51
|
import { matchPackBackendRoute } from './pack-backend-routes.js';
|
|
52
|
+
import { invokePackHttpRoute } from '../host/http-routes.js';
|
|
53
|
+
import { installProductCli } from '../cli-install.js';
|
|
51
54
|
import { collectTurnFiles } from './turn-files.js';
|
|
52
55
|
import { resolveMentionedPaths } from './mentioned-paths.js';
|
|
53
56
|
import { getAuthProvider } from '../auth/index.js';
|
|
@@ -675,31 +678,48 @@ export class LocalBackendRouter {
|
|
|
675
678
|
};
|
|
676
679
|
}
|
|
677
680
|
if (method === 'DELETE') {
|
|
678
|
-
|
|
679
|
-
|
|
681
|
+
const lease = this.tryChatLock(chatId);
|
|
682
|
+
if (!lease)
|
|
683
|
+
return this.chatBusy();
|
|
684
|
+
try {
|
|
685
|
+
if (url.searchParams.get('onlyIfEmpty') === '1') {
|
|
686
|
+
const deleted = await this.store.deleteChatIfEmpty(chatId);
|
|
687
|
+
return {
|
|
688
|
+
status: 200,
|
|
689
|
+
data: { success: true, deleted, chatId },
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const ok = await this.store.deleteChat(chatId);
|
|
693
|
+
if (!ok)
|
|
694
|
+
return this.notFound('Chat not found');
|
|
680
695
|
return {
|
|
681
696
|
status: 200,
|
|
682
|
-
data: {
|
|
697
|
+
data: {
|
|
698
|
+
success: true,
|
|
699
|
+
message: '删除成功',
|
|
700
|
+
chatId,
|
|
701
|
+
},
|
|
683
702
|
};
|
|
684
703
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
return {
|
|
689
|
-
status: 200,
|
|
690
|
-
data: {
|
|
691
|
-
success: true,
|
|
692
|
-
message: '删除成功',
|
|
693
|
-
chatId,
|
|
694
|
-
},
|
|
695
|
-
};
|
|
704
|
+
finally {
|
|
705
|
+
lease.release();
|
|
706
|
+
}
|
|
696
707
|
}
|
|
697
708
|
}
|
|
698
709
|
const chatPinMatch = pathname.match(/^\/api\/v2\/chats\/([^/]+)\/pin$/);
|
|
699
710
|
if (chatPinMatch && method === 'PUT') {
|
|
700
711
|
const chatId = chatPinMatch[1];
|
|
701
712
|
const payload = this.toRecord(request.body);
|
|
702
|
-
const
|
|
713
|
+
const lease = this.tryChatLock(chatId);
|
|
714
|
+
if (!lease)
|
|
715
|
+
return this.chatBusy();
|
|
716
|
+
let updated;
|
|
717
|
+
try {
|
|
718
|
+
updated = await this.store.updateChat(chatId, { isPinned: Boolean(payload.isPinned) });
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
lease.release();
|
|
722
|
+
}
|
|
703
723
|
if (!updated)
|
|
704
724
|
return this.notFound('Chat not found');
|
|
705
725
|
return {
|
|
@@ -737,12 +757,21 @@ export class LocalBackendRouter {
|
|
|
737
757
|
return this.badRequest('projectId 必须是项目 id 字符串或 null');
|
|
738
758
|
}
|
|
739
759
|
}
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
760
|
+
const lease = this.tryChatLock(chatId);
|
|
761
|
+
if (!lease)
|
|
762
|
+
return this.chatBusy();
|
|
763
|
+
let updated;
|
|
764
|
+
try {
|
|
765
|
+
updated = await this.store.updateChat(chatId, {
|
|
766
|
+
title: typeof payload.title === 'string' ? payload.title : undefined,
|
|
767
|
+
systemPrompt: typeof payload.systemPrompt === 'string' ? payload.systemPrompt : undefined,
|
|
768
|
+
pinnedRefs: Array.isArray(payload.pinnedRefs) ? payload.pinnedRefs : undefined,
|
|
769
|
+
projectId: projectIdUpdate,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
finally {
|
|
773
|
+
lease.release();
|
|
774
|
+
}
|
|
746
775
|
if (!updated)
|
|
747
776
|
return this.notFound('Chat not found');
|
|
748
777
|
return {
|
|
@@ -1488,6 +1517,19 @@ export class LocalBackendRouter {
|
|
|
1488
1517
|
// W-llm-diagnose:LLM 链路诊断。设置页「诊断」按钮触发,在主进程内
|
|
1489
1518
|
// 探测 DNS/TCP/TLS/HTTP/chat 五级连通性,并报告宿主机的 ambient 代理
|
|
1490
1519
|
// 配置(sidecar 沙箱视角会隐藏用户需要看到的代理问题)。
|
|
1520
|
+
if (method === 'POST' && pathname === '/api/v2/cli/install') {
|
|
1521
|
+
const payload = this.toRecord(request.body);
|
|
1522
|
+
const name = typeof payload.name === 'string' ? payload.name : '';
|
|
1523
|
+
try {
|
|
1524
|
+
return { status: 200, data: installProductCli(name) };
|
|
1525
|
+
}
|
|
1526
|
+
catch (error) {
|
|
1527
|
+
return {
|
|
1528
|
+
status: 400,
|
|
1529
|
+
data: { error: error instanceof Error ? error.message : String(error) },
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1491
1533
|
if (method === 'POST' && pathname === '/api/v2/llm/diagnose') {
|
|
1492
1534
|
const payload = this.toRecord(request.body);
|
|
1493
1535
|
const settings = llmService.getSettings();
|
|
@@ -1854,6 +1896,9 @@ export class LocalBackendRouter {
|
|
|
1854
1896
|
body: request.body,
|
|
1855
1897
|
});
|
|
1856
1898
|
}
|
|
1899
|
+
const packHttp = await invokePackHttpRoute(method, pathname, request.body);
|
|
1900
|
+
if (packHttp)
|
|
1901
|
+
return packHttp;
|
|
1857
1902
|
return this.fallbackResponse(method, pathname);
|
|
1858
1903
|
}
|
|
1859
1904
|
/**
|
|
@@ -1949,242 +1994,244 @@ export class LocalBackendRouter {
|
|
|
1949
1994
|
emit(this.sse('error', { message: 'chat not found' }));
|
|
1950
1995
|
return { status: 404 };
|
|
1951
1996
|
}
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
1997
|
+
return this.withChatWriteLock(chatId, emit, async () => {
|
|
1998
|
+
// W7-1: resume=true(仅 send 路由)续跑 durable record 里被中断的 turn,
|
|
1999
|
+
// 不追加新用户消息;与 regenerate 互斥(regenerate 有自己的路径参数语义)。
|
|
2000
|
+
const isResume = !regenerateMatch && payload.resume === true;
|
|
2001
|
+
let userMessageText;
|
|
2002
|
+
if (regenerateMatch) {
|
|
2003
|
+
// Regenerate = truncate-and-rerun, *not* "append a fresh 'please
|
|
2004
|
+
// regenerate' user turn on top of the old reply". The old bug ignored
|
|
2005
|
+
// the :messageId path param entirely, so regenerating just piled a new
|
|
2006
|
+
// user+assistant pair onto history while the original (possibly bad)
|
|
2007
|
+
// assistant reply stayed in place — the model saw both and had no real
|
|
2008
|
+
// reason to answer differently.
|
|
2009
|
+
//
|
|
2010
|
+
// sidecar 门必须先于任何写操作:rerun 回合只能跑在 sidecar 上
|
|
2011
|
+
// (handleCoreLoopTurn 无 sidecar 直接 503)。若先截断再 503,旧回复
|
|
2012
|
+
// 已删、新回复不会产生、record 也不存在——非破坏性承诺破窗。
|
|
2013
|
+
if (!getSidecarSupervisor()) {
|
|
2014
|
+
emit(this.sse('error', { message: 'coreloop enabled but sidecar is not running' }));
|
|
2015
|
+
return { status: 503 };
|
|
2016
|
+
}
|
|
2017
|
+
const targetMessageId = regenerateMatch[2];
|
|
2018
|
+
// The user turn that prompted the target reply is whatever immediately
|
|
2019
|
+
// precedes it — re-derive its text so buildConversationMessages gets
|
|
2020
|
+
// the *real* request instead of a generic placeholder (matters for
|
|
2021
|
+
// deferred-exec detection, mode preamble, etc., which all key off the
|
|
2022
|
+
// actual ask).
|
|
2023
|
+
const priorMessages = (await this.store.listMessages(chatId, 200)).reverse();
|
|
2024
|
+
const resolution = resolveRegenerateContext(priorMessages, targetMessageId);
|
|
2025
|
+
if (!resolution.ok) {
|
|
2026
|
+
emit(this.sse('error', { message: 'regenerate target message not found or not an assistant message' }));
|
|
2027
|
+
return { status: 404 };
|
|
2028
|
+
}
|
|
2029
|
+
userMessageText = resolution.userMessageText;
|
|
2030
|
+
// W5-2: fork the durable record BEFORE truncating the UI store, so
|
|
2031
|
+
// the old tail survives as a discoverable branch instead of being
|
|
2032
|
+
// destroyed (UI store) / interleaved with the new tail (record).
|
|
2033
|
+
// The fork point is addressed by user-message ordinal: the prompting
|
|
2034
|
+
// user turn is the last user message before the target, i.e. index
|
|
2035
|
+
// (count of user messages before the target) - 1. An ordinal the sidecar
|
|
2036
|
+
// declines to split — no record, or a point inside a branch seed — is the
|
|
2037
|
+
// protocol's documented fallback and truncates; a fork that failed instead
|
|
2038
|
+
// of answering refuses the request (planRegenerateTruncate).
|
|
2039
|
+
const supervisor = getSidecarSupervisor();
|
|
2040
|
+
const outcome = supervisor
|
|
2041
|
+
? await supervisor.forkSession({
|
|
2042
|
+
recordId: await this.store.getChatRecordId(chatId) ?? chatId,
|
|
2043
|
+
beforeUserIndex: resolveRegenerateForkOrdinal(priorMessages, targetMessageId),
|
|
2044
|
+
newRecordId: `${chatId}:r${Date.now().toString(36)}`,
|
|
2045
|
+
})
|
|
2046
|
+
: null;
|
|
2047
|
+
if (outcome?.ok) {
|
|
2048
|
+
await this.store.setChatRecordId(chatId, outcome.fork.recordId);
|
|
2049
|
+
console.log('[local-backend] regenerate: record forked (old tail preserved)', {
|
|
2050
|
+
chatId,
|
|
2051
|
+
branchRecordId: outcome.fork.recordId,
|
|
2052
|
+
label: outcome.fork.label,
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
else if (outcome) {
|
|
2056
|
+
console.log('[local-backend] regenerate: no fork', {
|
|
2057
|
+
chatId,
|
|
2058
|
+
declined: outcome.declined,
|
|
2059
|
+
reason: outcome.reason,
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
const plan = planRegenerateTruncate(outcome);
|
|
2063
|
+
if (!plan.proceed) {
|
|
2064
|
+
emit(this.sse('error', { message: plan.message }));
|
|
2065
|
+
return { status: 409 };
|
|
2066
|
+
}
|
|
2067
|
+
const deleted = await this.store.deleteMessagesFrom(chatId, targetMessageId);
|
|
2068
|
+
console.log('[local-backend] regenerate: truncated history', {
|
|
2004
2069
|
chatId,
|
|
2005
|
-
|
|
2006
|
-
|
|
2070
|
+
targetMessageId,
|
|
2071
|
+
deletedCount: deleted,
|
|
2007
2072
|
});
|
|
2008
2073
|
}
|
|
2009
|
-
else if (
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2074
|
+
else if (isResume) {
|
|
2075
|
+
// W7-1: 继续被中断的 turn —— 不追加新用户消息(记录里已有),而是让
|
|
2076
|
+
// sidecar 回放 durable record 的投影作为循环种子。只在检测确为
|
|
2077
|
+
// interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
|
|
2078
|
+
// 凭空多接一段回复。
|
|
2079
|
+
const prior = await this.store.listMessages(chatId, 200);
|
|
2080
|
+
const interrupted = detectInterruptedTurn({
|
|
2081
|
+
turnActive: await this.store.getTurnActive(chatId) !== null,
|
|
2082
|
+
streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
|
|
2083
|
+
lastMessageRole: prior[0]?.role ?? null,
|
|
2014
2084
|
});
|
|
2085
|
+
const lastUser = prior.find(m => m.role === 'user');
|
|
2086
|
+
if (!interrupted || !lastUser) {
|
|
2087
|
+
emit(this.sse('error', { message: '没有可继续的中断回复' }));
|
|
2088
|
+
return { status: 409 };
|
|
2089
|
+
}
|
|
2090
|
+
// 触发轮的用户消息仍在库里(崩溃只丢了回复)——取其原文喂给
|
|
2091
|
+
// buildConversationMessages,让 mode preamble / deferred-exec 检测
|
|
2092
|
+
// 仍然基于真实诉求。发给 sidecar 的 messages 为空(见下方调用点)。
|
|
2093
|
+
userMessageText = lastUser.content;
|
|
2015
2094
|
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2095
|
+
else {
|
|
2096
|
+
userMessageText = String(payload.message || '').trim();
|
|
2097
|
+
}
|
|
2098
|
+
if (!userMessageText) {
|
|
2099
|
+
emit(this.sse('error', { message: 'message is required' }));
|
|
2100
|
+
return { status: 400 };
|
|
2101
|
+
}
|
|
2102
|
+
const cleanedResult = await this.cleanUserMessage(userMessageText);
|
|
2103
|
+
const cleanUserMessageText = cleanedResult.cleanText;
|
|
2104
|
+
const forcedSkillName = cleanedResult.skillName;
|
|
2105
|
+
const forcedMcpToolToken = cleanedResult.mcpToolToken;
|
|
2106
|
+
// 仅在以下条件全部成立时才在本轮结束后生成 AI 标题:
|
|
2107
|
+
// 1. 当前标题仍是 storage.createChat 默认值「新对话」(用户没自己改过)
|
|
2108
|
+
// 2. 这是非 regenerate 的真实 user turn(regenerate 不应改写标题)
|
|
2109
|
+
// 3. 历史里目前没有任何 assistant 消息——也就是说,本轮即将产生的是第一条助手回复
|
|
2110
|
+
// 这避免在已经聊到一半的对话里突然把标题换掉。listMessages 是 DESC,limit 拉
|
|
2111
|
+
// 大一点确保看到完整历史(200 已经覆盖绝大多数本地会话)。
|
|
2112
|
+
const existingAssistant = (await this.store
|
|
2113
|
+
.listMessages(chatId, 200))
|
|
2114
|
+
.some((m) => m.role === 'assistant');
|
|
2115
|
+
const shouldGenerateTitle = !regenerateMatch &&
|
|
2116
|
+
!isResume &&
|
|
2117
|
+
!existingAssistant &&
|
|
2118
|
+
(chat.title === '新对话' || chat.title.trim() === '');
|
|
2119
|
+
const firstUserMessageForTitle = cleanUserMessageText;
|
|
2120
|
+
console.log('[local-backend] title-gen decision', {
|
|
2023
2121
|
chatId,
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
else if (isResume) {
|
|
2029
|
-
// W7-1: 继续被中断的 turn —— 不追加新用户消息(记录里已有),而是让
|
|
2030
|
-
// sidecar 回放 durable record 的投影作为循环种子。只在检测确为
|
|
2031
|
-
// interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
|
|
2032
|
-
// 凭空多接一段回复。
|
|
2033
|
-
const prior = await this.store.listMessages(chatId, 200);
|
|
2034
|
-
const interrupted = detectInterruptedTurn({
|
|
2035
|
-
turnActive: await this.store.getTurnActive(chatId) !== null,
|
|
2036
|
-
streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
|
|
2037
|
-
lastMessageRole: prior[0]?.role ?? null,
|
|
2122
|
+
shouldGenerateTitle,
|
|
2123
|
+
currentTitle: chat.title,
|
|
2124
|
+
isRegenerate: !!regenerateMatch,
|
|
2125
|
+
existingAssistant,
|
|
2038
2126
|
});
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
emit(this.sseData({ type: 'user_message', message: userMessage }));
|
|
2098
|
-
}
|
|
2099
|
-
// Plan 模式(类 Cursor "先出计划"):仅暴露只读工具给模型,引导它先调研、
|
|
2100
|
-
// 再输出结构化计划,不做任何写操作/执行。其余值一律视为常规 agent 模式。
|
|
2101
|
-
const chatMode = resolveTurnChatMode(payload.mode);
|
|
2102
|
-
// 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
|
|
2103
|
-
// 策略决定工具列表,工具列表又决定技能的触发条件。
|
|
2104
|
-
const turnAgents = await this.resolveTurnAgents(chatId, payload, mentionedAgentIds);
|
|
2105
|
-
// Wave 2 工具分层:模型可见列表只出 direct 层(内置工具 + tool_search
|
|
2106
|
-
// 发现缝);MCP 动态工具全在 deferred 层,经 tool_search 命中即调。
|
|
2107
|
-
// 附带收益:MCP 目录增删不再改动系统提示词里的工具名录,前缀更稳。
|
|
2108
|
-
const turnTools = this.toolRouter
|
|
2109
|
-
.listModelSchemas(turnAgents.capability.toolPolicy)
|
|
2110
|
-
.filter(schema => chatMode !== 'plan' || schema.mode === 'read')
|
|
2111
|
-
.map(schema => ({
|
|
2112
|
-
name: schema.name,
|
|
2113
|
-
description: schema.description,
|
|
2114
|
-
inputSchema: schema.inputSchema,
|
|
2115
|
-
}));
|
|
2116
|
-
// 用户通过 "/" 显式指定的 MCP 工具:从注册表补齐元信息,available 取决于
|
|
2117
|
-
// 该工具本轮是否真的在 turnTools 里(未启用/未连接/被 plan 模式过滤都算
|
|
2118
|
-
// 不可用——指令模块会据此渲染"强制调用"或"如实告知不可用"两种版本)。
|
|
2119
|
-
let forcedMcpTool;
|
|
2120
|
-
if (forcedMcpToolToken) {
|
|
2121
|
-
const resolved = this.toolRouter.mcpRegistry?.findToolByToken(forcedMcpToolToken) ?? null;
|
|
2122
|
-
const token = resolved?.token ?? forcedMcpToolToken;
|
|
2123
|
-
const tokenParts = token.split('__');
|
|
2124
|
-
// 显式指定的工具属于 deferred 层、不在模型可见列表——本轮直接提升
|
|
2125
|
-
// 进列表(用户点名 = 最强信号),省一轮 tool_search 往返。
|
|
2126
|
-
// 智能体的工具策略不因点名而让步:被拒的工具不提升,forcedMcpTool
|
|
2127
|
-
// 随之落到 available: false 分支(指令模块如实告知不可用)。
|
|
2128
|
-
if (resolved &&
|
|
2129
|
-
!turnTools.some((t) => t.name === token) &&
|
|
2130
|
-
isToolAllowed(turnAgents.capability.toolPolicy, token)) {
|
|
2131
|
-
const schema = this.toolRouter.getSchemaByName(token);
|
|
2132
|
-
if (schema && (chatMode !== 'plan' || schema.mode === 'read')) {
|
|
2133
|
-
turnTools.push({
|
|
2134
|
-
name: schema.name,
|
|
2135
|
-
description: schema.description,
|
|
2136
|
-
inputSchema: schema.inputSchema,
|
|
2137
|
-
});
|
|
2127
|
+
// Regenerate reruns against the (now-truncated) existing history — the
|
|
2128
|
+
// triggering user message is already in it, so unlike `send` we must not
|
|
2129
|
+
// insert another copy or the model would see it twice. Resume (W7-1)
|
|
2130
|
+
// likewise appends nothing: the crashed turn's user message is already
|
|
2131
|
+
// persisted (and already in the durable record).
|
|
2132
|
+
let currentUserMessageId;
|
|
2133
|
+
// 本轮被点名的智能体:菜单点选带来的 id 优先,手打的 `@名字` 从正文
|
|
2134
|
+
// 解析补齐(两者缺一,提及就在后端消失,只剩前端徽章)。
|
|
2135
|
+
const chatAgents = await this.store.listChatAgents();
|
|
2136
|
+
const mentionedAgentIds = resolveMentionedAgentIds(payload, userMessageText, chatAgents);
|
|
2137
|
+
if (!regenerateMatch && !isResume) {
|
|
2138
|
+
const userMeta = mentionedAgentIds.length > 0
|
|
2139
|
+
? JSON.stringify({ mentionedAgentIds })
|
|
2140
|
+
: null;
|
|
2141
|
+
const userMessage = await this.store.addMessage(chatId, 'user', userMessageText, userMeta);
|
|
2142
|
+
currentUserMessageId = userMessage.id;
|
|
2143
|
+
emit(this.sseData({ type: 'user_message', message: userMessage }));
|
|
2144
|
+
}
|
|
2145
|
+
// Plan 模式(类 Cursor "先出计划"):仅暴露只读工具给模型,引导它先调研、
|
|
2146
|
+
// 再输出结构化计划,不做任何写操作/执行。其余值一律视为常规 agent 模式。
|
|
2147
|
+
const chatMode = resolveTurnChatMode(payload.mode);
|
|
2148
|
+
// 本轮生效的智能体:人设、技能勾选、工具策略同源。必须先解析——工具
|
|
2149
|
+
// 策略决定工具列表,工具列表又决定技能的触发条件。
|
|
2150
|
+
const turnAgents = await this.resolveTurnAgents(chatId, payload, mentionedAgentIds);
|
|
2151
|
+
// Wave 2 工具分层:模型可见列表只出 direct 层(内置工具 + tool_search
|
|
2152
|
+
// 发现缝);MCP 动态工具全在 deferred 层,经 tool_search 命中即调。
|
|
2153
|
+
// 附带收益:MCP 目录增删不再改动系统提示词里的工具名录,前缀更稳。
|
|
2154
|
+
const turnTools = this.toolRouter
|
|
2155
|
+
.listModelSchemas(turnAgents.capability.toolPolicy)
|
|
2156
|
+
.filter(schema => chatMode !== 'plan' || schema.mode === 'read')
|
|
2157
|
+
.map(schema => ({
|
|
2158
|
+
name: schema.name,
|
|
2159
|
+
description: schema.description,
|
|
2160
|
+
inputSchema: schema.inputSchema,
|
|
2161
|
+
}));
|
|
2162
|
+
// 用户通过 "/" 显式指定的 MCP 工具:从注册表补齐元信息,available 取决于
|
|
2163
|
+
// 该工具本轮是否真的在 turnTools 里(未启用/未连接/被 plan 模式过滤都算
|
|
2164
|
+
// 不可用——指令模块会据此渲染"强制调用"或"如实告知不可用"两种版本)。
|
|
2165
|
+
let forcedMcpTool;
|
|
2166
|
+
if (forcedMcpToolToken) {
|
|
2167
|
+
const resolved = this.toolRouter.mcpRegistry?.findToolByToken(forcedMcpToolToken) ?? null;
|
|
2168
|
+
const token = resolved?.token ?? forcedMcpToolToken;
|
|
2169
|
+
const tokenParts = token.split('__');
|
|
2170
|
+
// 显式指定的工具属于 deferred 层、不在模型可见列表——本轮直接提升
|
|
2171
|
+
// 进列表(用户点名 = 最强信号),省一轮 tool_search 往返。
|
|
2172
|
+
// 智能体的工具策略不因点名而让步:被拒的工具不提升,forcedMcpTool
|
|
2173
|
+
// 随之落到 available: false 分支(指令模块如实告知不可用)。
|
|
2174
|
+
if (resolved &&
|
|
2175
|
+
!turnTools.some((t) => t.name === token) &&
|
|
2176
|
+
isToolAllowed(turnAgents.capability.toolPolicy, token)) {
|
|
2177
|
+
const schema = this.toolRouter.getSchemaByName(token);
|
|
2178
|
+
if (schema && (chatMode !== 'plan' || schema.mode === 'read')) {
|
|
2179
|
+
turnTools.push({
|
|
2180
|
+
name: schema.name,
|
|
2181
|
+
description: schema.description,
|
|
2182
|
+
inputSchema: schema.inputSchema,
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2138
2185
|
}
|
|
2186
|
+
forcedMcpTool = {
|
|
2187
|
+
token,
|
|
2188
|
+
toolName: resolved?.toolName ?? tokenParts.slice(2).join('__'),
|
|
2189
|
+
serverName: resolved?.serverName ?? '',
|
|
2190
|
+
description: resolved?.description ?? '',
|
|
2191
|
+
available: turnTools.some((t) => t.name === token),
|
|
2192
|
+
};
|
|
2139
2193
|
}
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
emit,
|
|
2182
|
-
signal,
|
|
2183
|
-
shouldGenerateTitle,
|
|
2184
|
-
firstUserMessageForTitle,
|
|
2185
|
-
resume: isResume,
|
|
2186
|
-
subagent: mergeTurnSubagentParam(Object.fromEntries(mentionRoster.map((row) => [row.profileName, row.profile])), Object.fromEntries(ambientRoster.map((row) => [row.profileName, row.profile]))),
|
|
2187
|
-
parentAgentId: turnAgents.parent?.id ?? null,
|
|
2194
|
+
const turnToolNames = turnTools.map((tool) => tool.name);
|
|
2195
|
+
const mentionRoster = await buildMentionDelegateRoster(turnAgents.delegates, turnToolNames);
|
|
2196
|
+
// 常驻可委派名单:其余智能体也进画像,技能/提示里的「交给 X」无需用户
|
|
2197
|
+
// `@` 就能落成一次真实委派。父代理自己不进——它就是本轮的执行者,给它
|
|
2198
|
+
// 一个自己的画像只会诱导无意义的自委派(`@自己` 走提及那条路,仍然可以)。
|
|
2199
|
+
const ambientRoster = await buildAmbientDelegateRoster(chatAgents, turnToolNames, {
|
|
2200
|
+
excludeAgentIds: [
|
|
2201
|
+
...mentionRoster.map((row) => row.agentId),
|
|
2202
|
+
...(turnAgents.parent ? [turnAgents.parent.id] : []),
|
|
2203
|
+
],
|
|
2204
|
+
reservedProfileNames: [
|
|
2205
|
+
...Object.keys(BUILTIN_SUBAGENT_PROFILES),
|
|
2206
|
+
...mentionRoster.map((row) => row.profileName),
|
|
2207
|
+
],
|
|
2208
|
+
});
|
|
2209
|
+
const { systemPrompt, messages, skillContext } = await this.buildConversationMessages(chatId, cleanUserMessageText, payload, turnTools, turnAgents, forcedSkillName, chatMode, forcedMcpTool, currentUserMessageId, { mention: mentionRoster, ambient: ambientRoster });
|
|
2210
|
+
// A4: the sidecar-hosted CoreLoop is the only chat path (the TS loop
|
|
2211
|
+
// was deleted 2026-08-26 after default-on + canary verification). Tools
|
|
2212
|
+
// round-trip back to this process over the reverse channel. If the
|
|
2213
|
+
// sidecar isn't running, handleCoreLoopTurn fails loud with a 503.
|
|
2214
|
+
return this.handleCoreLoopTurn({
|
|
2215
|
+
chatId,
|
|
2216
|
+
systemPrompt,
|
|
2217
|
+
// W7-1: on resume the sidecar replays the durable record's projection
|
|
2218
|
+
// as the loop seed and rejects a non-empty host history — the record
|
|
2219
|
+
// is authoritative, so the freshly built history stays unsent (only
|
|
2220
|
+
// systemPrompt / skillContext ride along).
|
|
2221
|
+
messages: isResume ? [] : messages,
|
|
2222
|
+
skillContext,
|
|
2223
|
+
turnTools,
|
|
2224
|
+
toolPolicy: turnAgents.capability.toolPolicy,
|
|
2225
|
+
chatMode,
|
|
2226
|
+
payload,
|
|
2227
|
+
emit,
|
|
2228
|
+
signal,
|
|
2229
|
+
shouldGenerateTitle,
|
|
2230
|
+
firstUserMessageForTitle,
|
|
2231
|
+
resume: isResume,
|
|
2232
|
+
subagent: mergeTurnSubagentParam(Object.fromEntries(mentionRoster.map((row) => [row.profileName, row.profile])), Object.fromEntries(ambientRoster.map((row) => [row.profileName, row.profile]))),
|
|
2233
|
+
parentAgentId: turnAgents.parent?.id ?? null,
|
|
2234
|
+
});
|
|
2188
2235
|
});
|
|
2189
2236
|
}
|
|
2190
2237
|
/**
|
|
@@ -2952,6 +2999,35 @@ export class LocalBackendRouter {
|
|
|
2952
2999
|
}
|
|
2953
3000
|
return String(value);
|
|
2954
3001
|
}
|
|
3002
|
+
chatBusy() {
|
|
3003
|
+
return {
|
|
3004
|
+
status: 409,
|
|
3005
|
+
data: { code: 'chat_busy', message: '该会话正在另一个进程中运行' },
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
3008
|
+
tryChatLock(chatId) {
|
|
3009
|
+
try {
|
|
3010
|
+
return acquireChatWriteLock(getUserDataDir(), chatId);
|
|
3011
|
+
}
|
|
3012
|
+
catch (error) {
|
|
3013
|
+
if (error instanceof ChatBusyError)
|
|
3014
|
+
return null;
|
|
3015
|
+
throw error;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
async withChatWriteLock(chatId, emit, body) {
|
|
3019
|
+
const lease = this.tryChatLock(chatId);
|
|
3020
|
+
if (!lease) {
|
|
3021
|
+
emit(this.sse('error', { code: 'chat_busy', message: '该会话正在另一个进程中运行' }));
|
|
3022
|
+
return { status: 409 };
|
|
3023
|
+
}
|
|
3024
|
+
try {
|
|
3025
|
+
return await body();
|
|
3026
|
+
}
|
|
3027
|
+
finally {
|
|
3028
|
+
lease.release();
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
2955
3031
|
sse(event, data) {
|
|
2956
3032
|
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
2957
3033
|
}
|