@@ -54,13 +54,16 @@ import { buildL2Injection, refineL2 } from '../storage/memory/l2'
5454import { getClient } from '../llm/retry'
5555import { resolveThinkingConfig } from '../llm/thinking'
5656import { listToolsForAgents } from '../tools/mcp'
57+ import { filterToolsByAllowlist } from '../tools/allowlist'
5758import { listMemoryKeysForPrompt } from '../tools/builtin/memory'
5859import { resolveApprovalDecision } from '../tools/sessionApprovals'
5960import {
6061 newRequestId ,
61- rejectAllUserInputs ,
62+ newRunId ,
63+ rejectUserInputsForRun ,
6264 waitForUserInput ,
6365} from '../orchestrator/userInput'
66+ import { listDrafts , removeDraft , writeDraft } from '../crash-recovery'
6467import type { BuildDeps } from '../orchestrator/builder'
6568import type { AgentExecutorOptions } from '../orchestrator/patterns/agent'
6669import { logger } from '../logger'
@@ -76,13 +79,49 @@ const DEFAULT_USER_ID = 'local'
7679
7780/** 当前聊天的 AbortController(home:cancel 用;组队运行内 ask_user 挂起也受它取消) */
7881let currentAbortController : AbortController | null = null
82+ /** 当前首页 HITL run 作用域(与 orchestrate 通道隔离) */
83+ let currentHitlRunId : string | null = null
7984
80- /** 创建提案草稿暂存(draftId → {draft, ts});确认/取消删除,超时(30min)惰性清理。 */
85+ /** 创建提案草稿暂存(draftId → {draft, ts});确认/取消删除,超时(30min)惰性清理;同步落盘防崩溃丢失 。 */
8186const DRAFT_TTL_MS = 30 * 60 * 1000
8287/** 草稿驻留硬上限:正常流程单 figure 确认即删到不了上限;防异常 propose 风暴撑内存 */
8388const MAX_PENDING_DRAFTS = 100
89+ const CREATE_DRAFT_PREFIX = 'create-'
8490const pendingDrafts = new Map < string , { draft : CreateDraft ; ts : number } > ( )
8591
92+ function createDraftFileName ( draftId : string ) : string {
93+ // draftId 已是 uuid 形态;剥路径字符防穿越
94+ const safe = draftId . replace ( / [ / \\ ] / g, '' )
95+ return `${ CREATE_DRAFT_PREFIX } ${ safe } .json`
96+ }
97+
98+ function persistCreateDraft ( draftId : string , entry : { draft : CreateDraft ; ts : number } ) : void {
99+ writeDraft ( createDraftFileName ( draftId ) , JSON . stringify ( entry ) )
100+ }
101+
102+ function forgetCreateDraft ( draftId : string ) : void {
103+ pendingDrafts . delete ( draftId )
104+ removeDraft ( createDraftFileName ( draftId ) )
105+ }
106+
107+ /** 启动时从 drafts/ 水合未确认创建卡(崩溃恢复后 listPendingDrafts 可重挂) */
108+ function hydrateCreateDraftsFromDisk ( ) : void {
109+ for ( const f of listDrafts ( ) ) {
110+ if ( ! f . name . startsWith ( CREATE_DRAFT_PREFIX ) || ! f . name . endsWith ( '.json' ) ) continue
111+ try {
112+ const parsed = JSON . parse ( f . content ) as { draft ?: CreateDraft ; ts ?: number }
113+ if ( ! parsed ?. draft ?. draftId || typeof parsed . ts !== 'number' ) continue
114+ if ( Date . now ( ) - parsed . ts > DRAFT_TTL_MS ) {
115+ removeDraft ( f . name )
116+ continue
117+ }
118+ pendingDrafts . set ( parsed . draft . draftId , { draft : parsed . draft , ts : parsed . ts } )
119+ } catch {
120+ removeDraft ( f . name )
121+ }
122+ }
123+ }
124+
86125/** propose_* 工具结果是否表示失败(Zod invalid_args / empty_payload 等) */
87126function parseProposeFailure ( content : string ) : {
88127 error : string
@@ -117,7 +156,7 @@ function parseProposeFailure(content: string): {
117156function pruneDrafts ( ) : void {
118157 const now = Date . now ( )
119158 for ( const [ id , entry ] of pendingDrafts ) {
120- if ( now - entry . ts > DRAFT_TTL_MS ) pendingDrafts . delete ( id )
159+ if ( now - entry . ts > DRAFT_TTL_MS ) forgetCreateDraft ( id )
121160 }
122161}
123162
@@ -152,6 +191,7 @@ function makeCompressFn(
152191}
153192
154193export function registerHomeHandlers ( ) : void {
194+ hydrateCreateDraftsFromDisk ( )
155195 withHandler < { runId : string } > ( 'home:chat' , async ( _e , input ) => {
156196 const { message, sessionId } = input as { message : string ; sessionId ?: string }
157197
@@ -263,9 +303,13 @@ export function registerHomeHandlers(): void {
263303 // 虽 JS 闭包延迟取值不会报错,但先创建可避免 TDZ 风险 + 代码意图更清晰
264304 if ( currentAbortController ) {
265305 logger . warn ( '[home] 已有运行中的聊天,自动取消旧运行' )
306+ const prevRun = currentHitlRunId
266307 currentAbortController . abort ( )
308+ if ( prevRun ) rejectUserInputsForRun ( prevRun , 'aborted' )
267309 }
268310 currentAbortController = new AbortController ( )
311+ const hitlRunId = newRunId ( 'home' )
312+ currentHitlRunId = hitlRunId
269313 const { signal } = currentAbortController
270314
271315 // R1/R2:builtin + 显式 exposeToAgents 且已连接的 MCP 工具(同一快照供主 Agent / 组队节点共用)
@@ -323,9 +367,11 @@ export function registerHomeHandlers(): void {
323367 // 超上限挤掉最旧草稿(Map 迭代序即插入序)
324368 if ( pendingDrafts . size >= MAX_PENDING_DRAFTS && ! pendingDrafts . has ( stamped . draftId ) ) {
325369 const oldest = pendingDrafts . keys ( ) . next ( ) . value
326- if ( oldest ) pendingDrafts . delete ( oldest )
370+ if ( oldest ) forgetCreateDraft ( oldest )
327371 }
328- pendingDrafts . set ( stamped . draftId , { draft : stamped , ts : Date . now ( ) } )
372+ const entry = { draft : stamped , ts : Date . now ( ) }
373+ pendingDrafts . set ( stamped . draftId , entry )
374+ persistCreateDraft ( stamped . draftId , entry )
329375 emitStream ( { type : 'proposal' , draft : stamped } )
330376 logger . info ( `[home:create] propose invoked: kind=${ stamped . kind } draftId=${ stamped . draftId } ` )
331377 } ,
@@ -337,7 +383,7 @@ export function registerHomeHandlers(): void {
337383 emitStream ( { type : 'orch_event' , event } )
338384 emit ( { type : 'request_info' , request_id : requestId , node_id : 'home' , question, context } )
339385 try {
340- const answer = await waitForUserInput ( requestId , { nodeId : 'home' , question } , signal )
386+ const answer = await waitForUserInput ( requestId , { nodeId : 'home' , question } , signal , hitlRunId )
341387 emit ( { type : 'request_resolved' , request_id : requestId , node_id : 'home' , response : answer } )
342388 return answer
343389 } catch ( e ) {
@@ -353,7 +399,12 @@ export function registerHomeHandlers(): void {
353399 emitStream ( { type : 'orch_event' , event } )
354400 emit ( { type : 'approval_request' , request_id : requestId , node_id : 'home' , tool_name : toolName , args } )
355401 try {
356- const response = await waitForUserInput ( requestId , { nodeId : 'home' , question : `approve ${ toolName } ` } , signal )
402+ const response = await waitForUserInput (
403+ requestId ,
404+ { nodeId : 'home' , question : `approve ${ toolName } ` } ,
405+ signal ,
406+ hitlRunId ,
407+ )
357408 emit ( { type : 'approval_resolved' , request_id : requestId , node_id : 'home' , response } )
358409 return resolveApprovalDecision ( response , sid , toolName )
359410 } catch ( e ) {
@@ -372,10 +423,13 @@ export function registerHomeHandlers(): void {
372423 instructions ?: string
373424 description ?: string
374425 skillIds ?: string [ ]
426+ allowedToolNames ?: string [ ]
375427 modelId ?: string
376428 temperature ?: number
377429 maxTokens ?: number
378430 outputConstraints ?: string
431+ sourceAgentId ?: string
432+ sourceCapabilityId ?: string
379433 }
380434 const nodeThinking = resolveThinkingConfig (
381435 d . modelId ?? modelId ,
@@ -395,21 +449,30 @@ export function registerHomeHandlers(): void {
395449 const finalNodeInstructions = d . outputConstraints
396450 ? `${ nodeInstructions } \n\n【输出约束】\n${ d . outputConstraints } `
397451 : nodeInstructions
452+ // 资产级工具白名单:节点快照 → 源角色 → 源能力
453+ let allow = d . allowedToolNames
454+ if ( ! allow ?. length && d . sourceAgentId ) {
455+ allow = getAgent ( d . sourceAgentId ) ?. allowedToolNames
456+ }
457+ if ( ! allow ?. length && d . sourceCapabilityId ) {
458+ allow = getCapability ( d . sourceCapabilityId ) ?. allowedToolNames
459+ }
460+ const nodeTools = filterToolsByAllowlist ( agentTools , allow )
398461 const cfg : AgentConfig = {
399462 // 铁律20:executor_id == 节点 id(runner 按节点 id 路由/查找),
400463 // 不能用 d.label(角色显示名)——否则 executors.get(node.id) 找不到 → 空白气泡
401464 name : node . id ,
402465 description : d . description ,
403466 instructions : finalNodeInstructions ,
404467 modelId : d . modelId ?? modelId ,
405- tools : agentTools ,
468+ tools : nodeTools ,
406469 defaultOptions : { maxTokens : d . maxTokens ?? 16384 , temperature : d . temperature } ,
407470 outputConstraints : d . outputConstraints ,
408471 thinking : nodeThinking ,
409472 }
410473 const opts : AgentExecutorOptions = {
411474 config : cfg ,
412- llmOpts : { apiKey, baseURL, authHeader } ,
475+ llmOpts : { apiKey, baseURL, authHeader, apiFormat } ,
413476 toolCtx : {
414477 sessionId : sid ,
415478 signal,
@@ -421,7 +484,12 @@ export function registerHomeHandlers(): void {
421484 emitStream ( { type : 'orch_event' , event } )
422485 emit ( { type : 'request_info' , request_id : requestId , node_id : node . id , question, context } )
423486 try {
424- const answer = await waitForUserInput ( requestId , { nodeId : node . id , question } , signal )
487+ const answer = await waitForUserInput (
488+ requestId ,
489+ { nodeId : node . id , question } ,
490+ signal ,
491+ hitlRunId ,
492+ )
425493 emit ( { type : 'request_resolved' , request_id : requestId , node_id : node . id , response : answer } )
426494 return answer
427495 } catch ( e ) {
@@ -436,7 +504,12 @@ export function registerHomeHandlers(): void {
436504 emitStream ( { type : 'orch_event' , event } )
437505 emit ( { type : 'approval_request' , request_id : requestId , node_id : node . id , tool_name : toolName , args } )
438506 try {
439- const response = await waitForUserInput ( requestId , { nodeId : node . id , question : `approve ${ toolName } ` } , signal )
507+ const response = await waitForUserInput (
508+ requestId ,
509+ { nodeId : node . id , question : `approve ${ toolName } ` } ,
510+ signal ,
511+ hitlRunId ,
512+ )
440513 emit ( { type : 'approval_resolved' , request_id : requestId , node_id : node . id , response } )
441514 return resolveApprovalDecision ( response , sid , toolName )
442515 } catch ( e ) {
@@ -695,22 +768,26 @@ export function registerHomeHandlers(): void {
695768 emitStream ( { type : 'message_stop' , stop_reason : 'error' } )
696769 throw e
697770 } finally {
698- // 聊天结束(含异常/取消):驳回残留挂起提问 + 清控制器,防泄漏到下一场。
699- // 只清自己的控制器:若期间新运行已接管(入口自动取消旧运行),不动新句柄
700- rejectAllUserInputs ( 'run_finished' )
771+ // 仅驳回本 run 的挂起提问,避免误伤编辑器 orchestrate 通道
772+ rejectUserInputsForRun ( hitlRunId , 'run_finished' )
701773 // SkillContextProvider.afterRun(铁律22):运行结束审计
702774 for ( const p of skillProviders ) p . afterRun ( )
703- if ( currentAbortController ?. signal === signal ) currentAbortController = null
775+ if ( currentAbortController ?. signal === signal ) {
776+ currentAbortController = null
777+ if ( currentHitlRunId === hitlRunId ) currentHitlRunId = null
778+ }
704779 }
705780
706781 return { runId : sid }
707782 } )
708783
709784 withHandler < void > ( 'home:cancel' , ( ) => {
710- rejectAllUserInputs ( 'aborted' ) // 先驳回挂起提问,让工具侧收尾
785+ const runId = currentHitlRunId
786+ if ( runId ) rejectUserInputsForRun ( runId , 'aborted' )
711787 if ( currentAbortController ) {
712788 currentAbortController . abort ( )
713789 currentAbortController = null
790+ currentHitlRunId = null
714791 logger . info ( '[home:cancel] 已取消当前聊天/组队运行' )
715792 }
716793 } )
@@ -778,7 +855,7 @@ export function registerHomeHandlers(): void {
778855 throw new Error ( `未知创建类型:${ String ( kind ) } ` )
779856 }
780857
781- pendingDrafts . delete ( draftId )
858+ forgetCreateDraft ( draftId )
782859 // R3:confirm 成功后写 meta.create.status=confirmed(供 B 期降级/事实源)
783860 const sessionId = cached ?. sessionId
784861 if ( sessionId ) {
@@ -801,7 +878,7 @@ export function registerHomeHandlers(): void {
801878 // 前端确认卡点「取消」:丢弃草稿,不入库。
802879 withHandler < void > ( 'home:cancelCreate' , ( _e , input ) => {
803880 const { draftId } = input as { draftId : string }
804- pendingDrafts . delete ( draftId )
881+ forgetCreateDraft ( draftId )
805882 logger . info ( '[home:create] 已取消草稿:' , draftId )
806883 } )
807884
0 commit comments