@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.
- package/README.md +8 -8
- package/package.json +1 -1
- package/src/app/api/settings/route.ts +1 -0
- package/src/app/api/wallets/[id]/send/route.ts +10 -4
- package/src/app/api/wallets/route.ts +5 -2
- package/src/app/settings/page.tsx +9 -0
- package/src/components/wallets/wallet-panel.tsx +12 -1
- package/src/components/wallets/wallet-section.tsx +4 -1
- package/src/lib/server/agents/main-agent-loop-advanced.test.ts +35 -0
- package/src/lib/server/agents/main-agent-loop.ts +12 -1
- package/src/lib/server/agents/subagent-runtime.test.ts +99 -16
- package/src/lib/server/agents/subagent-runtime.ts +115 -19
- package/src/lib/server/agents/subagent-swarm.ts +3 -3
- package/src/lib/server/chat-execution/chat-execution-session-sync.test.ts +112 -0
- package/src/lib/server/chat-execution/chat-execution.ts +357 -152
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +51 -0
- package/src/lib/server/chat-execution/stream-agent-chat.ts +201 -38
- package/src/lib/server/chat-execution/stream-continuation.ts +46 -0
- package/src/lib/server/connectors/contact-boundaries.ts +70 -8
- package/src/lib/server/connectors/manager.test.ts +129 -7
- package/src/lib/server/plugins.test.ts +263 -0
- package/src/lib/server/plugins.ts +406 -10
- package/src/lib/server/session-tools/context.ts +15 -1
- package/src/lib/server/session-tools/index.ts +42 -6
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +50 -0
- package/src/lib/server/session-tools/subagent.ts +3 -3
- package/src/lib/server/tool-loop-detection.test.ts +21 -0
- package/src/lib/server/tool-loop-detection.ts +79 -0
- package/src/lib/server/wallet/wallet-service.test.ts +25 -1
- package/src/lib/server/wallet/wallet-service.ts +13 -0
- package/src/types/index.ts +134 -1
- 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
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
100
|
+
return extractMemoryLabels(entry).some((label) => labelMatchesSenderName(label, normalizedSenderName))
|
|
54
101
|
}
|
|
55
102
|
|
|
56
|
-
function memoryDefinesQuietBoundary(
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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 || '',
|
|
157
|
+
const explicitlyAddressed = textMentionsAlias(msg.text || '', aliases)
|
|
96
158
|
|| isReplyToLastOutbound(msg, session)
|
|
97
159
|
|
|
98
160
|
return explicitlyAddressed
|