@swarmclawai/swarmclaw 0.9.4 → 0.9.6

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.
Files changed (32) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/src/app/api/settings/route.ts +1 -0
  4. package/src/app/api/wallets/[id]/send/route.ts +10 -4
  5. package/src/app/api/wallets/route.ts +5 -2
  6. package/src/app/settings/page.tsx +9 -0
  7. package/src/components/wallets/wallet-panel.tsx +12 -1
  8. package/src/components/wallets/wallet-section.tsx +4 -1
  9. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
  10. package/src/lib/server/agents/main-agent-loop.ts +12 -1
  11. package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
  12. package/src/lib/server/agents/subagent-runtime.ts +115 -19
  13. package/src/lib/server/agents/subagent-swarm.ts +3 -3
  14. package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
  15. package/src/lib/server/chat-execution/chat-execution.ts +357 -152
  16. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
  17. package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
  18. package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
  19. package/src/lib/server/connectors/contact-boundaries.ts +70 -8
  20. package/src/lib/server/connectors/manager.test.ts +129 -7
  21. package/src/lib/server/plugins.test.ts +263 -0
  22. package/src/lib/server/plugins.ts +406 -10
  23. package/src/lib/server/session-tools/context.ts +15 -1
  24. package/src/lib/server/session-tools/index.ts +42 -6
  25. package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
  26. package/src/lib/server/session-tools/subagent.ts +3 -3
  27. package/src/lib/server/tool-loop-detection.test.ts +21 -0
  28. package/src/lib/server/tool-loop-detection.ts +79 -0
  29. package/src/lib/server/wallet/wallet-service.test.ts +25 -1
  30. package/src/lib/server/wallet/wallet-service.ts +13 -0
  31. package/src/types/index.ts +134 -1
  32. package/src/views/settings/section-wallets.tsx +35 -0
@@ -14,6 +14,7 @@ import {
14
14
  resolveFinalStreamResponseText,
15
15
  shouldSkipToolSummaryForShortResponse,
16
16
  shouldForceAttachmentFollowthrough,
17
+ shouldForceExternalExecutionKickoffFollowthrough,
17
18
  shouldForceRecoverableToolErrorFollowthrough,
18
19
  shouldTerminateOnSuccessfulMemoryMutation,
19
20
  shouldForceDeliverableFollowthrough,
@@ -193,6 +194,15 @@ describe('buildToolDisciplineLines', () => {
193
194
  assert.ok(!streamAgentChatSource.includes('langchainMessages.push(new AIMessage({ content: fullText }))'))
194
195
  })
195
196
 
197
+ it('wires prompt-build hooks and pre-tool loop guards into the runtime path', () => {
198
+ assert.ok(streamAgentChatSource.includes('runBeforePromptBuild'))
199
+ assert.ok(streamAgentChatSource.includes('applyBeforePromptBuildResult'))
200
+ assert.ok(streamAgentChatSource.includes('beforeToolCall: ({ toolName, input }) =>'))
201
+ assert.ok(streamAgentChatSource.includes("phase: 'before_tool_call'"))
202
+ assert.ok(streamAgentChatSource.includes('loopTracker.preview(toolName, input)'))
203
+ assert.ok(streamAgentChatSource.includes('runId,'))
204
+ })
205
+
196
206
  it('forces early workspace-tool kickoff for explicit saved-artifact deliverables', () => {
197
207
  assert.ok(streamAgentChatSource.includes('shouldEnforceEarlyRequiredToolKickoff'))
198
208
  assert.ok(streamAgentChatSource.includes('REQUIRED_TOOL_KICKOFF_TIMEOUT_MS'))
@@ -200,6 +210,12 @@ describe('buildToolDisciplineLines', () => {
200
210
  assert.ok(streamAgentChatSource.includes('did not start the required workspace tool step'))
201
211
  })
202
212
 
213
+ it('wires a bounded execution-kickoff continuation for intent-only live task replies', () => {
214
+ assert.ok(streamSources.includes('execution_kickoff_followthrough'))
215
+ assert.ok(streamAgentChatSource.includes('shouldForceExternalExecutionKickoffFollowthrough'))
216
+ assert.ok(streamAgentChatSource.includes('externalExecutionKickoff'))
217
+ })
218
+
203
219
  it('adds current-thread recall guidance and immediate memory routes in the system prompt', () => {
204
220
  assert.ok(streamAgentChatSource.includes('## Current Thread Recall'))
205
221
  assert.ok(streamAgentChatSource.includes('## Immediate Memory Routes'))
@@ -615,6 +631,41 @@ describe('shouldForceExternalExecutionFollowthrough', () => {
615
631
  })
616
632
  })
617
633
 
634
+ describe('shouldForceExternalExecutionKickoffFollowthrough', () => {
635
+ it('forces a bounded continuation when an execution task stops at an intent-only kickoff', () => {
636
+ assert.equal(
637
+ shouldForceExternalExecutionKickoffFollowthrough({
638
+ userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
639
+ finalResponse: 'Let me try to interact directly with the NFT contract and see if I can mint one:',
640
+ hasToolCalls: false,
641
+ toolEvents: [],
642
+ }),
643
+ true,
644
+ )
645
+ })
646
+
647
+ it('does not force kickoff when the model already surfaced a real blocker or asked a blocking question', () => {
648
+ assert.equal(
649
+ shouldForceExternalExecutionKickoffFollowthrough({
650
+ userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
651
+ finalResponse: 'Exact blocker: this wallet cannot complete the required signature in the current runtime.',
652
+ hasToolCalls: false,
653
+ toolEvents: [],
654
+ }),
655
+ false,
656
+ )
657
+ assert.equal(
658
+ shouldForceExternalExecutionKickoffFollowthrough({
659
+ userMessage: 'Try buy one NFT on Arbitrum and show me what happened.',
660
+ finalResponse: 'Which collection do you want me to target?',
661
+ hasToolCalls: false,
662
+ toolEvents: [],
663
+ }),
664
+ false,
665
+ )
666
+ })
667
+ })
668
+
618
669
  describe('shouldForceAttachmentFollowthrough', () => {
619
670
  it('forces a retry for attachment-backed research turns that still skipped tools', () => {
620
671
  assert.equal(
@@ -14,7 +14,7 @@ import { buildRuntimeSkillPromptBlocks, resolveRuntimeSkills } from '@/lib/serve
14
14
  import { logExecution } from '@/lib/server/execution-log'
15
15
  import { buildCurrentDateTimePromptContext } from '@/lib/server/prompt-runtime-context'
16
16
  import { canonicalizePluginId, expandPluginIds, pluginIdMatches } from '@/lib/server/tool-aliases'
17
- import type { Session, Message, UsageRecord, PluginInvocationRecord, MessageToolEvent } from '@/types'
17
+ import type { Session, Message, UsageRecord, PluginInvocationRecord, MessageToolEvent, PluginPromptBuildResult } from '@/types'
18
18
  import { extractSuggestions } from '@/lib/server/suggestions'
19
19
  import { buildIdentityContinuityContext } from '@/lib/server/identity-continuity'
20
20
  import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
@@ -38,6 +38,7 @@ import {
38
38
  looksLikeBoundedExternalExecutionTask,
39
39
  looksLikeOpenEndedDeliverableTask,
40
40
  shouldForceRecoverableToolErrorFollowthrough,
41
+ shouldForceExternalExecutionKickoffFollowthrough,
41
42
  shouldForceExternalExecutionFollowthrough,
42
43
  shouldForceDeliverableFollowthrough,
43
44
  hasStateChangingWalletEvidence,
@@ -88,6 +89,7 @@ export {
88
89
  getExplicitRequiredToolNames,
89
90
  isWalletSimulationResult,
90
91
  looksLikeOpenEndedDeliverableTask,
92
+ shouldForceExternalExecutionKickoffFollowthrough,
91
93
  shouldForceRecoverableToolErrorFollowthrough,
92
94
  shouldForceExternalExecutionFollowthrough,
93
95
  shouldForceDeliverableFollowthrough,
@@ -503,6 +505,32 @@ function buildCurrentThreadRecallBlock(history: Message[]): string {
503
505
  return lines.join('\n')
504
506
  }
505
507
 
508
+ function joinPromptSegments(...segments: Array<string | null | undefined>): string {
509
+ return segments
510
+ .map((segment) => (typeof segment === 'string' ? segment.trim() : ''))
511
+ .filter(Boolean)
512
+ .join('\n\n')
513
+ }
514
+
515
+ function applyBeforePromptBuildResult(
516
+ basePrompt: string,
517
+ hookResult: PluginPromptBuildResult | null | undefined,
518
+ ): string {
519
+ if (!hookResult) return basePrompt
520
+
521
+ const baseSystemPrompt = typeof hookResult.systemPrompt === 'string' && hookResult.systemPrompt.trim()
522
+ ? hookResult.systemPrompt.trim()
523
+ : basePrompt
524
+
525
+ const systemPromptWithContext = joinPromptSegments(
526
+ hookResult.prependSystemContext,
527
+ baseSystemPrompt,
528
+ hookResult.appendSystemContext,
529
+ ) || baseSystemPrompt
530
+
531
+ return joinPromptSegments(hookResult.prependContext, systemPromptWithContext) || systemPromptWithContext
532
+ }
533
+
506
534
  export interface StreamAgentChatResult {
507
535
  /** All text accumulated across every LLM turn (for SSE / web UI history). */
508
536
  fullText: string
@@ -802,21 +830,13 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
802
830
  }
803
831
 
804
832
  let prompt = promptParts.join('\n\n')
805
-
806
- const endToolBuildPerf = perf.start('stream-agent-chat', 'buildSessionTools', { sessionId: session.id })
807
- const { tools, cleanup, toolToPluginMap, abortSignalRef } = await buildSessionTools(session.cwd, sessionPlugins, {
808
- agentId: session.agentId,
809
- sessionId: session.id,
810
- platformAssignScope: agentPlatformAssignScope,
811
- mcpServerIds: agentMcpServerIds,
812
- mcpDisabledTools: agentMcpDisabledTools,
813
- projectId: activeProjectContext.projectId,
814
- projectRoot: activeProjectContext.projectRoot,
815
- projectName: activeProjectContext.project?.name || null,
816
- projectDescription: activeProjectContext.project?.description || null,
817
- memoryScopeMode: agentMemoryScopeMode,
833
+ const runId = `${session.id}:${startTs}`
834
+ const loopTracker = new ToolLoopTracker({
835
+ ...(typeof settings.toolLoopFrequencyWarn === 'number' && { toolFrequencyWarn: settings.toolLoopFrequencyWarn }),
836
+ ...(typeof settings.toolLoopFrequencyCritical === 'number' && { toolFrequencyCritical: settings.toolLoopFrequencyCritical }),
837
+ ...(typeof settings.toolLoopCircuitBreaker === 'number' && { circuitBreaker: settings.toolLoopCircuitBreaker }),
818
838
  })
819
- endToolBuildPerf({ toolCount: tools.length })
839
+ const emittedPreToolWarnings = new Set<string>()
820
840
  const recursionLimit = getAgentLoopRecursionLimit(runtime)
821
841
 
822
842
  // Build message history for context
@@ -971,6 +991,43 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
971
991
  // Context manager failure — continue with recent history
972
992
  }
973
993
 
994
+ const langchainMessages: Array<HumanMessage | AIMessage> = []
995
+ for (const m of effectiveHistory) {
996
+ if (m.role === 'user') {
997
+ const resolvedImg = resolveImagePath(m.imagePath, m.imageUrl)
998
+ langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, resolvedImg ?? undefined, m.attachedFiles) }))
999
+ } else {
1000
+ langchainMessages.push(new AIMessage({ content: m.text }))
1001
+ }
1002
+ }
1003
+
1004
+ // Add current message
1005
+ const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
1006
+ langchainMessages.push(new HumanMessage({ content: currentContent }))
1007
+
1008
+ const pluginMgr = getPluginManager()
1009
+ const promptHookResult = await pluginMgr.runBeforePromptBuild(
1010
+ {
1011
+ session,
1012
+ prompt,
1013
+ message,
1014
+ history,
1015
+ messages: [
1016
+ ...effectiveHistory,
1017
+ {
1018
+ role: 'user',
1019
+ text: message,
1020
+ time: Date.now(),
1021
+ ...(imagePath ? { imagePath } : {}),
1022
+ ...(imageUrl ? { imageUrl } : {}),
1023
+ ...(attachedFiles?.length ? { attachedFiles } : {}),
1024
+ },
1025
+ ],
1026
+ },
1027
+ { enabledIds: sessionPlugins },
1028
+ )
1029
+ prompt = applyBeforePromptBuildResult(prompt, promptHookResult)
1030
+
974
1031
  // Context degradation warning: prepend warning to system prompt when nearing limits
975
1032
  try {
976
1033
  const {
@@ -996,13 +1053,85 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
996
1053
  },
997
1054
  )
998
1055
  if (warning) {
999
- promptParts.unshift(warning)
1000
- prompt = promptParts.join('\n\n')
1056
+ prompt = joinPromptSegments(warning, prompt)
1001
1057
  }
1002
1058
  } catch {
1003
1059
  // Warning failure is non-critical
1004
1060
  }
1005
1061
 
1062
+ await pluginMgr.runHook(
1063
+ 'llmInput',
1064
+ {
1065
+ session,
1066
+ runId,
1067
+ provider: session.provider,
1068
+ model: session.model,
1069
+ systemPrompt: prompt,
1070
+ prompt: message,
1071
+ historyMessages: effectiveHistory,
1072
+ imagesCount: imagePath ? 1 : 0,
1073
+ },
1074
+ { enabledIds: sessionPlugins },
1075
+ )
1076
+
1077
+ const endToolBuildPerf = perf.start('stream-agent-chat', 'buildSessionTools', { sessionId: session.id })
1078
+ const { tools, cleanup, toolToPluginMap, abortSignalRef } = await buildSessionTools(session.cwd, sessionPlugins, {
1079
+ agentId: session.agentId,
1080
+ sessionId: session.id,
1081
+ runId,
1082
+ platformAssignScope: agentPlatformAssignScope,
1083
+ mcpServerIds: agentMcpServerIds,
1084
+ mcpDisabledTools: agentMcpDisabledTools,
1085
+ projectId: activeProjectContext.projectId,
1086
+ projectRoot: activeProjectContext.projectRoot,
1087
+ projectName: activeProjectContext.project?.name || null,
1088
+ projectDescription: activeProjectContext.project?.description || null,
1089
+ memoryScopeMode: agentMemoryScopeMode,
1090
+ beforeToolCall: ({ toolName, input }) => {
1091
+ const preview = loopTracker.preview(toolName, input)
1092
+ if (!preview) return undefined
1093
+ const previewKey = `${preview.severity}:${preview.detector}:${toolName}`
1094
+ if (preview.severity === 'warning' && emittedPreToolWarnings.has(previewKey)) {
1095
+ return undefined
1096
+ }
1097
+ if (preview.severity === 'warning') emittedPreToolWarnings.add(previewKey)
1098
+ logExecution(session.id, 'loop_detection', preview.message, {
1099
+ agentId: session.agentId,
1100
+ detail: {
1101
+ detector: preview.detector,
1102
+ severity: preview.severity,
1103
+ toolName,
1104
+ phase: 'before_tool_call',
1105
+ },
1106
+ })
1107
+ if (preview.severity === 'critical') {
1108
+ write(`data: ${JSON.stringify({
1109
+ t: 'status',
1110
+ text: JSON.stringify({
1111
+ loopDetection: preview.detector,
1112
+ severity: 'critical',
1113
+ message: preview.message,
1114
+ phase: 'before_tool_call',
1115
+ }),
1116
+ })}\n\n`)
1117
+ return { blockReason: preview.message }
1118
+ }
1119
+ return { warning: preview.message }
1120
+ },
1121
+ onToolCallWarning: ({ toolName, message }) => {
1122
+ write(`data: ${JSON.stringify({
1123
+ t: 'status',
1124
+ text: JSON.stringify({
1125
+ toolWarning: toolName,
1126
+ severity: 'warning',
1127
+ message,
1128
+ phase: 'before_tool_call',
1129
+ }),
1130
+ })}\n\n`)
1131
+ },
1132
+ })
1133
+ endToolBuildPerf({ toolCount: tools.length })
1134
+
1006
1135
  // Use a fresh in-memory checkpointer instead of the SQLite one. We manage
1007
1136
  // conversation history externally via langchainMessages — each iteration
1008
1137
  // receives full history, so no cross-iteration checkpoint state is needed.
@@ -1015,20 +1144,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1015
1144
  prompt,
1016
1145
  checkpointer: new MemorySaver(),
1017
1146
  })
1018
-
1019
- const langchainMessages: Array<HumanMessage | AIMessage> = []
1020
- for (const m of effectiveHistory) {
1021
- if (m.role === 'user') {
1022
- const resolvedImg = resolveImagePath(m.imagePath, m.imageUrl)
1023
- langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, resolvedImg ?? undefined, m.attachedFiles) }))
1024
- } else {
1025
- langchainMessages.push(new AIMessage({ content: m.text }))
1026
- }
1027
- }
1028
-
1029
- // Add current message
1030
- const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
1031
- langchainMessages.push(new HumanMessage({ content: currentContent }))
1032
1147
  let pendingGraphMessages = [...langchainMessages]
1033
1148
 
1034
1149
  let fullText = ''
@@ -1047,7 +1162,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1047
1162
  const likelyResearchSynthesisTask = routingDecision.intent === 'research' || routingDecision.intent === 'browsing'
1048
1163
 
1049
1164
  // Plugin hooks: beforeAgentStart
1050
- const pluginMgr = getPluginManager()
1051
1165
  await pluginMgr.runHook('beforeAgentStart', { session, message }, { enabledIds: sessionPlugins })
1052
1166
 
1053
1167
  const abortController = new AbortController()
@@ -1069,6 +1183,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1069
1183
  const MAX_TRANSIENT_RETRIES = 3
1070
1184
  const MAX_REQUIRED_TOOL_CONTINUES = 2
1071
1185
  let MAX_EXECUTION_FOLLOWTHROUGHS = 1
1186
+ const MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS = 1
1072
1187
  let MAX_ATTACHMENT_FOLLOWTHROUGHS = 1
1073
1188
  let MAX_DELIVERABLE_FOLLOWTHROUGHS = 2
1074
1189
  let MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS = 2
@@ -1090,6 +1205,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1090
1205
  let pendingRetryAfterMs: number | null = null
1091
1206
  let requiredToolContinueCount = 0
1092
1207
  let executionFollowthroughCount = 0
1208
+ let executionKickoffFollowthroughCount = 0
1093
1209
  let attachmentFollowthroughCount = 0
1094
1210
  let deliverableFollowthroughCount = 0
1095
1211
  let unfinishedToolFollowthroughCount = 0
@@ -1099,16 +1215,11 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1099
1215
  const shouldEnforceEarlyRequiredToolKickoff = explicitRequiredToolNames.length > 0
1100
1216
  && looksLikeOpenEndedDeliverableTask(message)
1101
1217
  const usedToolNames = new Set<string>()
1102
- const loopTracker = new ToolLoopTracker({
1103
- ...(typeof settings.toolLoopFrequencyWarn === 'number' && { toolFrequencyWarn: settings.toolLoopFrequencyWarn }),
1104
- ...(typeof settings.toolLoopFrequencyCritical === 'number' && { toolFrequencyCritical: settings.toolLoopFrequencyCritical }),
1105
- ...(typeof settings.toolLoopCircuitBreaker === 'number' && { circuitBreaker: settings.toolLoopCircuitBreaker }),
1106
- })
1107
1218
  let loopDetectionTriggered: LoopDetectionResult | null = null
1108
1219
  let terminalToolResponse = ''
1109
1220
 
1110
1221
  try {
1111
- const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
1222
+ const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
1112
1223
  for (let iteration = 0; iteration <= maxIterations; iteration++) {
1113
1224
  let shouldContinue: ContinuationType = false
1114
1225
  let requiredToolReminderNames: string[] = []
@@ -1659,6 +1770,31 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1659
1770
  })}\n\n`)
1660
1771
  }
1661
1772
 
1773
+ if (!shouldContinue
1774
+ && executionKickoffFollowthroughCount < MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS
1775
+ && shouldForceExternalExecutionKickoffFollowthrough({
1776
+ userMessage: message,
1777
+ finalResponse: resolveFinalStreamResponseText({
1778
+ fullText,
1779
+ lastSegment,
1780
+ lastSettledSegment,
1781
+ hasToolCalls,
1782
+ toolEvents: streamedToolEvents,
1783
+ }),
1784
+ hasToolCalls,
1785
+ toolEvents: streamedToolEvents,
1786
+ })) {
1787
+ shouldContinue = 'execution_kickoff_followthrough'
1788
+ executionKickoffFollowthroughCount++
1789
+ write(`data: ${JSON.stringify({
1790
+ t: 'status',
1791
+ text: JSON.stringify({
1792
+ externalExecutionKickoff: executionKickoffFollowthroughCount,
1793
+ maxFollowthroughs: MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS,
1794
+ }),
1795
+ })}\n\n`)
1796
+ }
1797
+
1662
1798
  if (!shouldContinue
1663
1799
  && executionFollowthroughCount < MAX_EXECUTION_FOLLOWTHROUGHS
1664
1800
  && shouldForceExternalExecutionFollowthrough({
@@ -1852,6 +1988,30 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1852
1988
  if (signal) signal.removeEventListener('abort', abortFromSignal)
1853
1989
  }
1854
1990
 
1991
+ const emitLlmOutputHook = async (response: string) => {
1992
+ const total = totalInputTokens + totalOutputTokens
1993
+ await pluginMgr.runHook(
1994
+ 'llmOutput',
1995
+ {
1996
+ session,
1997
+ runId,
1998
+ provider: session.provider,
1999
+ model: session.model,
2000
+ assistantTexts: response ? [response] : [],
2001
+ response,
2002
+ usage: total > 0
2003
+ ? {
2004
+ input: totalInputTokens,
2005
+ output: totalOutputTokens,
2006
+ total,
2007
+ estimatedCost: estimateCost(session.model, totalInputTokens, totalOutputTokens),
2008
+ }
2009
+ : undefined,
2010
+ },
2011
+ { enabledIds: sessionPlugins },
2012
+ )
2013
+ }
2014
+
1855
2015
  // Skip post-stream work if the client disconnected mid-stream
1856
2016
  if (signal?.aborted) {
1857
2017
  let finalResponse = resolveFinalStreamResponseText({
@@ -1878,6 +2038,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1878
2038
  finalResponse = forcedSummary
1879
2039
  }
1880
2040
  }
2041
+ await emitLlmOutputHook(finalResponse)
1881
2042
  await cleanup()
1882
2043
  return { fullText, finalResponse }
1883
2044
  }
@@ -1957,6 +2118,8 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1957
2118
  }
1958
2119
  }
1959
2120
 
2121
+ await emitLlmOutputHook(finalResponse)
2122
+
1960
2123
  // Plugin hooks: afterAgentComplete
1961
2124
  await pluginMgr.runHook('afterAgentComplete', { session, response: fullText }, { enabledIds: sessionPlugins })
1962
2125
 
@@ -20,6 +20,7 @@ export type ContinuationType =
20
20
  | 'transient'
21
21
  | 'required_tool'
22
22
  | 'attachment_followthrough'
23
+ | 'execution_kickoff_followthrough'
23
24
  | 'execution_followthrough'
24
25
  | 'deliverable_followthrough'
25
26
  | 'unfinished_tool_followthrough'
@@ -237,6 +238,28 @@ export function shouldForceExternalExecutionFollowthrough(params: {
237
238
  return /(let me|i'll|i will|trying|research|query|check|look|promising|now let me|good -|good,)/i.test(trimmed) || trimmed.length < 500
238
239
  }
239
240
 
241
+ export function shouldForceExternalExecutionKickoffFollowthrough(params: {
242
+ userMessage: string
243
+ finalResponse: string
244
+ hasToolCalls: boolean
245
+ toolEvents: MessageToolEvent[]
246
+ }): boolean {
247
+ if (!looksLikeBoundedExternalExecutionTask(params.userMessage)) return false
248
+ if (params.hasToolCalls || params.toolEvents.length > 0) return false
249
+
250
+ const trimmed = params.finalResponse.trim()
251
+ if (!trimmed) return true
252
+ if (/^(?:HEARTBEAT_OK|NO_MESSAGE)\b/i.test(trimmed)) return false
253
+ if (/\?\s*$/.test(trimmed)) return false
254
+ if (/\b(last reversible step|exact blocker|blocked|cannot|can't|missing capability|need approval|requires approval|approval boundary|requires human|ask_human|credential|authentication|login|2fa|mfa|captcha)\b/i.test(trimmed)) {
255
+ return false
256
+ }
257
+ if (/\b(done|completed|finished|sent|broadcast|minted|purchased|bought|swapped|claimed)\b/i.test(trimmed)) {
258
+ return false
259
+ }
260
+ return looksLikeIncompleteDeliverableResponse(trimmed) || trimmed.length < 220
261
+ }
262
+
240
263
  export function shouldForceDeliverableFollowthrough(params: {
241
264
  userMessage: string
242
265
  finalResponse: string
@@ -360,6 +383,23 @@ function buildExternalExecutionFollowthroughPrompt(params: {
360
383
  ].join('\n')
361
384
  }
362
385
 
386
+ function buildExternalExecutionKickoffPrompt(params: {
387
+ userMessage: string
388
+ fullText: string
389
+ }): string {
390
+ return [
391
+ 'The previous iteration stopped after an intent update before taking the first concrete execution step.',
392
+ 'Do not send another preamble like "let me check", "I will try", or "I\'m going to".',
393
+ 'Continue immediately from the same objective and take the first concrete reversible step now using the available tools.',
394
+ 'If a real blocker appears before any safe action, state the exact blocker with evidence instead of narrating your plan.',
395
+ 'Do not ask the user to repeat the task. Either act now or report the blocker.',
396
+ '',
397
+ `Objective:\n${params.userMessage}`,
398
+ '',
399
+ `Previous response:\n${params.fullText || '(none)'}`,
400
+ ].join('\n')
401
+ }
402
+
363
403
  function buildDeliverableFollowthroughPrompt(params: {
364
404
  userMessage: string
365
405
  fullText: string
@@ -578,6 +618,12 @@ export function buildContinuationPrompt(params: {
578
618
  fullText: params.fullText,
579
619
  })
580
620
 
621
+ case 'execution_kickoff_followthrough':
622
+ return buildExternalExecutionKickoffPrompt({
623
+ userMessage: params.message,
624
+ fullText: params.fullText,
625
+ })
626
+
581
627
  case 'execution_followthrough':
582
628
  return buildExternalExecutionFollowthroughPrompt({
583
629
  userMessage: params.message,
@@ -23,10 +23,52 @@ function collectSenderIds(
23
23
  ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0))
24
24
  }
25
25
 
26
+ function normalizeFreeText(value: string): string {
27
+ return value.trim().toLowerCase().replace(/\s+/g, ' ')
28
+ }
29
+
30
+ function extractMemoryLabels(entry: MemoryEntry): string[] {
31
+ const content = String(entry.content || '').trim()
32
+ const firstLine = content.split('\n', 1)[0]?.trim() || ''
33
+ const prefix = firstLine.includes(':') ? firstLine.split(':', 1)[0]?.trim() || '' : firstLine
34
+ return dedup([
35
+ String(entry.title || '').trim(),
36
+ prefix,
37
+ ].filter((value) => value.length > 0))
38
+ }
39
+
40
+ function labelMatchesSenderName(label: string, senderName: string): boolean {
41
+ const normalizedLabel = normalizeFreeText(label)
42
+ const normalizedSenderName = normalizeFreeText(senderName)
43
+ if (!normalizedLabel || !normalizedSenderName) return false
44
+ if (normalizedLabel === normalizedSenderName) return true
45
+ if (!normalizedLabel.startsWith(normalizedSenderName)) return false
46
+ const suffix = normalizedLabel.slice(normalizedSenderName.length).trim()
47
+ if (!suffix) return true
48
+ return /^[\s,.:;()+\-_/0-9@]+$/.test(suffix)
49
+ }
50
+
51
+ function extractExplicitMemoryIdentifiers(entry: MemoryEntry): string[] {
52
+ const metadata = entry.metadata as Record<string, unknown> | undefined
53
+ const metadataIdentifiers = Array.isArray(metadata?.identifiers)
54
+ ? metadata.identifiers.filter((value): value is string => typeof value === 'string')
55
+ : []
56
+ const text = `${entry.title || ''}\n${entry.content || ''}`
57
+ const jidLikeIdentifiers = text.match(/[0-9a-z_.:+-]+@[0-9a-z_.:-]+/gi) || []
58
+ return dedup([
59
+ ...metadataIdentifiers,
60
+ ...jidLikeIdentifiers,
61
+ ].map((value) => value.trim().toLowerCase()).filter(Boolean))
62
+ }
63
+
64
+ function escapeRegExp(value: string): string {
65
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
66
+ }
67
+
26
68
  function memoryMatchesSender(entry: MemoryEntry, senderIds: string[], senderName: string): boolean {
27
69
  const title = String(entry.title || '').toLowerCase()
28
70
  const content = String(entry.content || '').toLowerCase()
29
- const normalizedSenderName = senderName.trim().toLowerCase()
71
+ const normalizedSenderName = normalizeFreeText(senderName)
30
72
 
31
73
  for (const rawId of senderIds) {
32
74
  const lowered = rawId.toLowerCase()
@@ -42,6 +84,7 @@ function memoryMatchesSender(entry: MemoryEntry, senderIds: string[], senderName
42
84
  .map(toDigits)
43
85
  : []),
44
86
  ].filter((value) => value.length >= 6)
87
+ const explicitMemoryIdentifiers = extractExplicitMemoryIdentifiers(entry)
45
88
 
46
89
  for (const memoryDigit of memoryDigits) {
47
90
  for (const senderDigit of senderDigits) {
@@ -49,16 +92,34 @@ function memoryMatchesSender(entry: MemoryEntry, senderIds: string[], senderName
49
92
  }
50
93
  }
51
94
 
95
+ if (explicitMemoryIdentifiers.length > 0 || memoryDigits.length > 0) {
96
+ return false
97
+ }
98
+
52
99
  if (!normalizedSenderName) return false
53
- return title.includes(normalizedSenderName) || content.includes(normalizedSenderName)
100
+ return extractMemoryLabels(entry).some((label) => labelMatchesSenderName(label, normalizedSenderName))
54
101
  }
55
102
 
56
- function memoryDefinesQuietBoundary(entry: MemoryEntry): boolean {
103
+ function memoryDefinesQuietBoundary(
104
+ entry: MemoryEntry,
105
+ aliases: string[],
106
+ ): boolean {
107
+ const metadata = entry.metadata as Record<string, unknown> | undefined
108
+ if (metadata?.boundaryType === 'quiet_until_directly_addressed') return true
109
+ if (metadata?.directAddressRequired === true && metadata?.suppressReplies === true) return true
110
+
57
111
  const text = `${entry.title || ''}\n${entry.content || ''}`.toLowerCase()
58
112
  const boundaryRule = /\b(?:do not respond|do not reply|don't respond|don't reply|no replies|stay quiet|stay silent|remain quiet|be quiet)\b[\s\S]{0,140}\bunless\b/i
59
- const directAddressRule = /\b(?:address(?:es|ed)?|mention(?:s|ed)?|refer(?:s|red)?|talk(?:ing)? to)\b[\s\S]{0,80}\bhal\b/i
60
- const verifyRule = /\bverify whether\b[\s\S]{0,80}\b(?:wayde|hal)\b/i
61
- return boundaryRule.test(text) && (directAddressRule.test(text) || verifyRule.test(text))
113
+ const aliasPattern = dedup(aliases.map((alias) => normalizeFreeText(alias)).filter(Boolean))
114
+ .map((alias) => escapeRegExp(alias))
115
+ .join('|')
116
+ const aliasTargetRule = aliasPattern
117
+ ? new RegExp(`\\b(?:address(?:es|ed)?|mention(?:s|ed)?|refer(?:s|red)?|talk(?:ing)? to)\\b[\\s\\S]{0,80}\\b(?:${aliasPattern})\\b`, 'i')
118
+ : null
119
+ const genericTargetRule = /\b(?:address(?:es|ed)?|mention(?:s|ed)?|refer(?:s|red)?|talk(?:ing)? to)\b[\s\S]{0,80}\b(?:you|the agent|assistant|bot)\b/i
120
+ const verifyRule = /\bverify whether\b[\s\S]{0,120}\b(?:message|reply|response|it)\b[\s\S]{0,80}\b(?:is for|is meant for|was intended for|is addressed to)\b[\s\S]{0,80}\b(?:you|the agent|assistant|bot)\b/i
121
+ const directAddressRule = (aliasTargetRule ? aliasTargetRule.test(text) : false) || genericTargetRule.test(text)
122
+ return boundaryRule.test(text) && (directAddressRule || verifyRule.test(text))
62
123
  }
63
124
 
64
125
  function buildDirectAddressAliases(agent: Partial<Agent> | null | undefined, connector: Partial<Connector> | null | undefined): string[] {
@@ -85,14 +146,15 @@ export function enforceSenderQuietBoundary(params: {
85
146
  if (senderIds.length === 0 && !senderName.trim()) return { suppress: false }
86
147
 
87
148
  const memDb = getMemoryDb()
149
+ const aliases = buildDirectAddressAliases(agent, connector)
88
150
  const memories = memDb.list(agent.id, 200).filter((entry) =>
89
151
  entry.category?.startsWith('identity/')
90
152
  && memoryMatchesSender(entry, senderIds, senderName),
91
153
  )
92
- const matchedBoundary = memories.find(memoryDefinesQuietBoundary)
154
+ const matchedBoundary = memories.find((entry) => memoryDefinesQuietBoundary(entry, aliases))
93
155
  if (!matchedBoundary) return { suppress: false }
94
156
 
95
- const explicitlyAddressed = textMentionsAlias(msg.text || '', buildDirectAddressAliases(agent, connector))
157
+ const explicitlyAddressed = textMentionsAlias(msg.text || '', aliases)
96
158
  || isReplyToLastOutbound(msg, session)
97
159
 
98
160
  return explicitlyAddressed