@swarmclawai/swarmclaw 1.0.8 → 1.0.9
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 +5 -5
- package/package.json +1 -1
- package/src/lib/server/chat-execution/chat-execution-utils.test.ts +24 -0
- package/src/lib/server/chat-execution/chat-execution-utils.ts +5 -2
- package/src/lib/server/chat-execution/chat-execution.ts +5 -2
- package/src/lib/server/chat-execution/chat-streaming-utils.ts +4 -38
- package/src/lib/server/chat-execution/memory-mutation-tools.ts +53 -0
- package/src/lib/server/chat-execution/stream-agent-chat.test.ts +18 -3
- package/src/lib/server/chat-execution/stream-agent-chat.ts +76 -24
- package/src/lib/server/chat-execution/stream-continuation.ts +17 -0
- package/src/lib/server/connectors/contact-preferences.ts +118 -0
- package/src/lib/server/connectors/manager-roundtrip.test.ts +8 -2
- package/src/lib/server/connectors/manager.test.ts +231 -9
- package/src/lib/server/connectors/manager.ts +218 -72
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/voice-note.ts +80 -0
- package/src/lib/server/runtime/heartbeat-wake.test.ts +2 -2
- package/src/lib/server/runtime/heartbeat-wake.ts +8 -4
- package/src/lib/server/runtime/queue-reconcile.test.ts +262 -2
- package/src/lib/server/runtime/queue.ts +126 -226
- package/src/lib/server/runtime/scheduler.test.ts +126 -0
- package/src/lib/server/runtime/scheduler.ts +5 -6
- package/src/lib/server/runtime/session-run-manager.ts +2 -2
- package/src/lib/server/session-tools/connector.ts +24 -43
- package/src/lib/server/session-tools/memory.ts +38 -0
- package/src/types/index.ts +81 -4
package/README.md
CHANGED
|
@@ -17,12 +17,12 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
|
|
|
17
17
|
|
|
18
18
|
## Release Notes
|
|
19
19
|
|
|
20
|
-
### v1.0.
|
|
20
|
+
### v1.0.9 Highlights
|
|
21
21
|
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
22
|
+
- **Quieter chat and inbox replies**: chat-origin and connector turns now suppress more hidden control text, stop replaying connector-tool output as normal assistant prose, and avoid extra empty follow-up chatter after successful tool work.
|
|
23
|
+
- **Sender-aware direct inbox replies**: direct connector sessions can honor stored sender display names and reply-medium preferences, including voice-note-first replies when the connector supports binary media and the agent has a configured voice.
|
|
24
|
+
- **Cleaner connector delivery reconciliation**: connector delivery markers now track what was actually sent, response previews prefer the delivered transcript, and task/connector followups resolve local output files more reliably.
|
|
25
|
+
- **Memory-write followthrough hardening**: successful memory store/update turns terminate more cleanly, which reduces unnecessary post-tool loops while still allowing a natural acknowledgement when the user needs one.
|
|
26
26
|
|
|
27
27
|
## What SwarmClaw Focuses On
|
|
28
28
|
|
package/package.json
CHANGED
|
@@ -112,6 +112,10 @@ describe('shouldAutoRouteHeartbeatAlerts', () => {
|
|
|
112
112
|
assert.equal(shouldAutoRouteHeartbeatAlerts({ deliveryMode: 'tool_only' }), false)
|
|
113
113
|
})
|
|
114
114
|
|
|
115
|
+
it('returns false when deliveryMode is silent', () => {
|
|
116
|
+
assert.equal(shouldAutoRouteHeartbeatAlerts({ deliveryMode: 'silent' }), false)
|
|
117
|
+
})
|
|
118
|
+
|
|
115
119
|
it('returns true for default deliveryMode with showAlerts true', () => {
|
|
116
120
|
assert.equal(shouldAutoRouteHeartbeatAlerts({ showAlerts: true, deliveryMode: 'default' }), true)
|
|
117
121
|
})
|
|
@@ -253,6 +257,17 @@ describe('hasPersistableAssistantPayload', () => {
|
|
|
253
257
|
assert.equal(hasPersistableAssistantPayload('', '', [{ name: 'shell', input: 'ls' }]), true)
|
|
254
258
|
})
|
|
255
259
|
|
|
260
|
+
it('returns false for successful memory-write tool events without visible text', () => {
|
|
261
|
+
assert.equal(
|
|
262
|
+
hasPersistableAssistantPayload('', '', [{
|
|
263
|
+
name: 'memory_store',
|
|
264
|
+
input: '{"title":"Siobhan contact"}',
|
|
265
|
+
output: 'Stored memory "Siobhan contact" (id: abc123). No further memory lookup is needed unless the user asked you to verify.',
|
|
266
|
+
}]),
|
|
267
|
+
false,
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
|
|
256
271
|
it('returns false for all-whitespace text, thinking, and empty events', () => {
|
|
257
272
|
assert.equal(hasPersistableAssistantPayload(' ', ' ', []), false)
|
|
258
273
|
})
|
|
@@ -284,6 +299,15 @@ describe('getPersistedAssistantText', () => {
|
|
|
284
299
|
// buildToolEventAssistantSummary with empty array returns empty
|
|
285
300
|
assert.equal(result, '')
|
|
286
301
|
})
|
|
302
|
+
|
|
303
|
+
it('suppresses successful memory-write tool-only fallbacks', () => {
|
|
304
|
+
const result = getPersistedAssistantText('', [{
|
|
305
|
+
name: 'memory_store',
|
|
306
|
+
input: '{"title":"Siobhan contact"}',
|
|
307
|
+
output: 'Stored memory "Siobhan contact" (id: abc123). No further memory lookup is needed unless the user asked you to verify.',
|
|
308
|
+
}])
|
|
309
|
+
assert.equal(result, '')
|
|
310
|
+
})
|
|
287
311
|
})
|
|
288
312
|
|
|
289
313
|
// ---------------------------------------------------------------------------
|
|
@@ -6,6 +6,7 @@ import { getUsageSpendSince } from '@/lib/server/storage'
|
|
|
6
6
|
import { pluginIdMatches } from '@/lib/server/tool-aliases'
|
|
7
7
|
import { buildToolEventAssistantSummary } from '@/lib/chat/tool-event-summary'
|
|
8
8
|
import { looksLikePositiveConnectorDeliveryText } from '@/lib/server/chat-execution/chat-execution-connector-delivery'
|
|
9
|
+
import { hasOnlySuccessfulMemoryMutationToolEvents } from '@/lib/server/chat-execution/memory-mutation-tools'
|
|
9
10
|
import { getEnabledCapabilityIds } from '@/lib/capability-selection'
|
|
10
11
|
|
|
11
12
|
export interface SessionWithTools {
|
|
@@ -33,10 +34,10 @@ export function shouldApplySessionFreshnessReset(source: string): boolean {
|
|
|
33
34
|
|
|
34
35
|
export function shouldAutoRouteHeartbeatAlerts(config?: {
|
|
35
36
|
showAlerts?: boolean
|
|
36
|
-
deliveryMode?: 'default' | 'tool_only'
|
|
37
|
+
deliveryMode?: 'default' | 'tool_only' | 'silent'
|
|
37
38
|
} | null): boolean {
|
|
38
39
|
if (config?.showAlerts === false) return false
|
|
39
|
-
return config?.deliveryMode !== 'tool_only'
|
|
40
|
+
return config?.deliveryMode !== 'tool_only' && config?.deliveryMode !== 'silent'
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
export function shouldPersistInboundUserMessage(internal: boolean, source: string): boolean {
|
|
@@ -257,12 +258,14 @@ export function shouldSuppressRedundantConnectorDeliveryFollowup(params: {
|
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
export function hasPersistableAssistantPayload(text: string, thinking: string, toolEvents: MessageToolEvent[]): boolean {
|
|
261
|
+
if (!text.trim() && !thinking.trim() && hasOnlySuccessfulMemoryMutationToolEvents(toolEvents)) return false
|
|
260
262
|
return text.trim().length > 0 || thinking.trim().length > 0 || toolEvents.length > 0
|
|
261
263
|
}
|
|
262
264
|
|
|
263
265
|
export function getPersistedAssistantText(text: string, toolEvents: MessageToolEvent[]): string {
|
|
264
266
|
const trimmed = text.trim()
|
|
265
267
|
if (trimmed) return trimmed
|
|
268
|
+
if (hasOnlySuccessfulMemoryMutationToolEvents(toolEvents)) return ''
|
|
266
269
|
return buildToolEventAssistantSummary(toolEvents)
|
|
267
270
|
}
|
|
268
271
|
|
|
@@ -217,7 +217,7 @@ export interface ExecuteChatTurnInput {
|
|
|
217
217
|
showAlerts: boolean
|
|
218
218
|
target: string | null
|
|
219
219
|
lightContext?: boolean
|
|
220
|
-
deliveryMode?: 'default' | 'tool_only'
|
|
220
|
+
deliveryMode?: 'default' | 'tool_only' | 'silent'
|
|
221
221
|
}
|
|
222
222
|
replyToId?: string
|
|
223
223
|
}
|
|
@@ -1449,7 +1449,10 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
|
|
|
1449
1449
|
&& !hideAssistantTranscript
|
|
1450
1450
|
&& hasPersistableAssistantPayload(persistedText, thinkingText, persistedToolEvents)
|
|
1451
1451
|
&& heartbeatClassification !== 'suppress'
|
|
1452
|
-
&& !(isHeartbeatRun &&
|
|
1452
|
+
&& !(isHeartbeatRun && (
|
|
1453
|
+
heartbeatConfig?.deliveryMode === 'silent'
|
|
1454
|
+
|| (heartbeatConfig?.deliveryMode === 'tool_only' && !isDirectConnectorSession(session))
|
|
1455
|
+
))
|
|
1453
1456
|
|
|
1454
1457
|
const normalizeResumeId = (value: unknown): string | null =>
|
|
1455
1458
|
typeof value === 'string' && value.trim() ? value.trim() : null
|
|
@@ -4,6 +4,10 @@ import { extractSuggestions } from '@/lib/server/suggestions'
|
|
|
4
4
|
import {
|
|
5
5
|
looksLikeExternalWalletTask,
|
|
6
6
|
} from '@/lib/server/chat-execution/stream-continuation'
|
|
7
|
+
import {
|
|
8
|
+
resolveToolAction,
|
|
9
|
+
shouldTerminateOnSuccessfulMemoryMutation,
|
|
10
|
+
} from '@/lib/server/chat-execution/memory-mutation-tools'
|
|
7
11
|
|
|
8
12
|
const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export|create|generate)\b[^.!?\n]{0,80}\b(?:to|as|at|in)\b[^.!?\n]{0,60}(\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|~\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|\.\/[^\s,'"]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)|[a-z0-9._/-]+\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|py|ts|tsx|js|jsx|mjs|cjs|sql|sh)\b)/i
|
|
9
13
|
|
|
@@ -80,42 +84,6 @@ export function shouldForceExternalServiceSummary(params: {
|
|
|
80
84
|
return /:$/.test(trimmed) || /(let me|i'll|i will|checking|verify|promising|look into|explore|access their interface)/i.test(trimmed) || trimmed.length < 240
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
export function resolveToolAction(input: unknown): string {
|
|
84
|
-
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
85
|
-
const action = (input as Record<string, unknown>).action
|
|
86
|
-
return typeof action === 'string' ? action.trim().toLowerCase() : ''
|
|
87
|
-
}
|
|
88
|
-
if (typeof input !== 'string') return ''
|
|
89
|
-
const trimmed = input.trim()
|
|
90
|
-
if (!trimmed.startsWith('{')) return ''
|
|
91
|
-
try {
|
|
92
|
-
const parsed = JSON.parse(trimmed) as Record<string, unknown>
|
|
93
|
-
return typeof parsed.action === 'string' ? parsed.action.trim().toLowerCase() : ''
|
|
94
|
-
} catch {
|
|
95
|
-
return ''
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function shouldTerminateOnSuccessfulMemoryMutation(params: {
|
|
100
|
-
toolName: string
|
|
101
|
-
toolInput: unknown
|
|
102
|
-
toolOutput: string
|
|
103
|
-
}): boolean {
|
|
104
|
-
const canonicalToolName = canonicalizePluginId(params.toolName) || params.toolName
|
|
105
|
-
if (canonicalToolName !== 'memory') return false
|
|
106
|
-
const exactToolName = String(params.toolName || '').trim().toLowerCase()
|
|
107
|
-
const action = exactToolName === 'memory_store'
|
|
108
|
-
? 'store'
|
|
109
|
-
: exactToolName === 'memory_update'
|
|
110
|
-
? 'update'
|
|
111
|
-
: resolveToolAction(params.toolInput)
|
|
112
|
-
if (action !== 'store' && action !== 'update') return false
|
|
113
|
-
const output = extractSuggestions(params.toolOutput || '').clean.trim()
|
|
114
|
-
if (!output || /^error[:\s]/i.test(output)) return false
|
|
115
|
-
if (!/^(stored|updated) memory\b/i.test(output)) return false
|
|
116
|
-
return /no further memory lookup is needed unless the user asked you to verify/i.test(output)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
87
|
export type TerminalToolBoundary =
|
|
120
88
|
| { kind: 'memory_write'; responseText?: string }
|
|
121
89
|
| { kind: 'durable_wait' }
|
|
@@ -138,10 +106,8 @@ export function resolveSuccessfulTerminalToolBoundary(params: {
|
|
|
138
106
|
toolOutput: string
|
|
139
107
|
}): TerminalToolBoundary | null {
|
|
140
108
|
if (shouldTerminateOnSuccessfulMemoryMutation(params)) {
|
|
141
|
-
const responseText = extractSuggestions(params.toolOutput || '').clean.trim()
|
|
142
109
|
return {
|
|
143
110
|
kind: 'memory_write',
|
|
144
|
-
responseText: responseText || undefined,
|
|
145
111
|
}
|
|
146
112
|
}
|
|
147
113
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { MessageToolEvent } from '@/types'
|
|
2
|
+
import { canonicalizePluginId } from '@/lib/server/tool-aliases'
|
|
3
|
+
import { extractSuggestions } from '@/lib/server/suggestions'
|
|
4
|
+
|
|
5
|
+
export function resolveToolAction(input: unknown): string {
|
|
6
|
+
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
7
|
+
const action = (input as Record<string, unknown>).action
|
|
8
|
+
return typeof action === 'string' ? action.trim().toLowerCase() : ''
|
|
9
|
+
}
|
|
10
|
+
if (typeof input !== 'string') return ''
|
|
11
|
+
const trimmed = input.trim()
|
|
12
|
+
if (!trimmed.startsWith('{')) return ''
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>
|
|
15
|
+
return typeof parsed.action === 'string' ? parsed.action.trim().toLowerCase() : ''
|
|
16
|
+
} catch {
|
|
17
|
+
return ''
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldTerminateOnSuccessfulMemoryMutation(params: {
|
|
22
|
+
toolName: string
|
|
23
|
+
toolInput: unknown
|
|
24
|
+
toolOutput: string
|
|
25
|
+
}): boolean {
|
|
26
|
+
const canonicalToolName = canonicalizePluginId(params.toolName) || params.toolName
|
|
27
|
+
if (canonicalToolName !== 'memory') return false
|
|
28
|
+
const exactToolName = String(params.toolName || '').trim().toLowerCase()
|
|
29
|
+
const action = exactToolName === 'memory_store'
|
|
30
|
+
? 'store'
|
|
31
|
+
: exactToolName === 'memory_update'
|
|
32
|
+
? 'update'
|
|
33
|
+
: resolveToolAction(params.toolInput)
|
|
34
|
+
if (action !== 'store' && action !== 'update') return false
|
|
35
|
+
const output = extractSuggestions(params.toolOutput || '').clean.trim()
|
|
36
|
+
if (!output || /^error[:\s]/i.test(output)) return false
|
|
37
|
+
if (!/^(stored|updated) memory\b/i.test(output)) return false
|
|
38
|
+
return /no further memory lookup is needed unless the user asked you to verify/i.test(output)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isSuccessfulMemoryMutationToolEvent(event: Pick<MessageToolEvent, 'name' | 'input' | 'output'> | null | undefined): boolean {
|
|
42
|
+
if (!event || typeof event.name !== 'string') return false
|
|
43
|
+
return shouldTerminateOnSuccessfulMemoryMutation({
|
|
44
|
+
toolName: event.name,
|
|
45
|
+
toolInput: event.input,
|
|
46
|
+
toolOutput: typeof event.output === 'string' ? event.output : '',
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasOnlySuccessfulMemoryMutationToolEvents(toolEvents: MessageToolEvent[] | undefined): boolean {
|
|
51
|
+
const events = Array.isArray(toolEvents) ? toolEvents : []
|
|
52
|
+
return events.length > 0 && events.every((event) => isSuccessfulMemoryMutationToolEvent(event))
|
|
53
|
+
}
|
|
@@ -371,18 +371,22 @@ describe('resolveFinalStreamResponseText', () => {
|
|
|
371
371
|
assert.equal(result, 'Simple direct answer.')
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
-
it('
|
|
374
|
+
it('does not surface successful memory-write tool output when tool calls finished without prose', () => {
|
|
375
375
|
const result = resolveFinalStreamResponseText({
|
|
376
376
|
fullText: '',
|
|
377
377
|
lastSegment: '',
|
|
378
378
|
lastSettledSegment: '',
|
|
379
379
|
hasToolCalls: true,
|
|
380
380
|
toolEvents: [
|
|
381
|
-
{
|
|
381
|
+
{
|
|
382
|
+
name: 'memory_tool',
|
|
383
|
+
input: '{"action":"store","title":"Project Kodiak details"}',
|
|
384
|
+
output: 'Stored memory "Project Kodiak details" (id: abc123). No further memory lookup is needed unless the user asked you to verify.',
|
|
385
|
+
} as MessageToolEvent,
|
|
382
386
|
],
|
|
383
387
|
})
|
|
384
388
|
|
|
385
|
-
assert.equal(result, '
|
|
389
|
+
assert.equal(result, '')
|
|
386
390
|
})
|
|
387
391
|
|
|
388
392
|
it('surfaces a useful fallback from spawn_subagent JSON output', () => {
|
|
@@ -547,6 +551,17 @@ describe('shouldTerminateOnSuccessfulMemoryMutation', () => {
|
|
|
547
551
|
})
|
|
548
552
|
|
|
549
553
|
describe('resolveSuccessfulTerminalToolBoundary', () => {
|
|
554
|
+
it('treats successful memory writes as followthrough boundaries without surfacing raw tool text', () => {
|
|
555
|
+
assert.deepEqual(
|
|
556
|
+
resolveSuccessfulTerminalToolBoundary({
|
|
557
|
+
toolName: 'memory_store',
|
|
558
|
+
toolInput: { title: 'Brendon prefers to be called Jesus', value: 'Call him Jesus from now on.' },
|
|
559
|
+
toolOutput: 'Stored memory "Brendon prefers to be called Jesus" (id: abc123). No further memory lookup is needed unless the user asked you to verify.',
|
|
560
|
+
}),
|
|
561
|
+
{ kind: 'memory_write' },
|
|
562
|
+
)
|
|
563
|
+
})
|
|
564
|
+
|
|
550
565
|
it('treats durable ask_human waits as terminal boundaries', () => {
|
|
551
566
|
assert.deepEqual(
|
|
552
567
|
resolveSuccessfulTerminalToolBoundary({
|
|
@@ -73,11 +73,14 @@ import {
|
|
|
73
73
|
isWalletSimulationResult,
|
|
74
74
|
pruneIncompleteToolEvents,
|
|
75
75
|
resolveSuccessfulTerminalToolBoundary,
|
|
76
|
-
resolveToolAction,
|
|
77
76
|
shouldForceExternalServiceSummary,
|
|
78
|
-
shouldTerminateOnSuccessfulMemoryMutation,
|
|
79
77
|
updateStreamedToolEvents,
|
|
80
78
|
} from '@/lib/server/chat-execution/chat-streaming-utils'
|
|
79
|
+
import {
|
|
80
|
+
hasOnlySuccessfulMemoryMutationToolEvents,
|
|
81
|
+
resolveToolAction,
|
|
82
|
+
shouldTerminateOnSuccessfulMemoryMutation,
|
|
83
|
+
} from '@/lib/server/chat-execution/memory-mutation-tools'
|
|
81
84
|
import { LangGraphToolEventTracker } from '@/lib/server/chat-execution/tool-event-tracker'
|
|
82
85
|
|
|
83
86
|
// LangGraph's streamEvents leaves dangling internal promises when the for-await
|
|
@@ -383,6 +386,7 @@ function buildAgenticExecutionPolicy(opts: {
|
|
|
383
386
|
heartbeatPrompt: string
|
|
384
387
|
heartbeatIntervalSec: number
|
|
385
388
|
allowSilentReplies?: boolean
|
|
389
|
+
isDirectConnectorSession?: boolean
|
|
386
390
|
delegationEnabled?: boolean
|
|
387
391
|
userMessage?: string
|
|
388
392
|
history?: Message[]
|
|
@@ -422,6 +426,12 @@ function buildAgenticExecutionPolicy(opts: {
|
|
|
422
426
|
'If the user asks about prior work, decisions, dates, people, preferences, or todos from earlier conversations, start with `memory_search`. Use `memory_get` only when you need one targeted follow-up read.',
|
|
423
427
|
'Do not use `manage_tasks`, `manage_agents`, or `delegate` as a substitute for a direct memory write or recall step.',
|
|
424
428
|
)
|
|
429
|
+
if (opts.isDirectConnectorSession) {
|
|
430
|
+
parts.push(
|
|
431
|
+
'For direct connector chats, when storing a standing sender preference such as preferred name or reply medium, include structured `metadata.connectorPreference` fields so runtime can reuse them without reparsing prose.',
|
|
432
|
+
'Use fields like `preferredDisplayName` and `preferredReplyMedium:"voice_note"` when they are relevant.',
|
|
433
|
+
)
|
|
434
|
+
}
|
|
425
435
|
}
|
|
426
436
|
if (hasTooling) {
|
|
427
437
|
parts.push(
|
|
@@ -556,6 +566,8 @@ export interface StreamAgentChatResult {
|
|
|
556
566
|
/** Text from only the final LLM turn — after the last tool call completed.
|
|
557
567
|
* Use this for connector delivery so intermediate planning text isn't sent. */
|
|
558
568
|
finalResponse: string
|
|
569
|
+
/** Tool events emitted during the streamed run. */
|
|
570
|
+
toolEvents: MessageToolEvent[]
|
|
559
571
|
}
|
|
560
572
|
|
|
561
573
|
type LangChainContentPart =
|
|
@@ -823,6 +835,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
823
835
|
heartbeatPrompt,
|
|
824
836
|
heartbeatIntervalSec,
|
|
825
837
|
allowSilentReplies: isConnectorSession,
|
|
838
|
+
isDirectConnectorSession: isConnectorSession,
|
|
826
839
|
delegationEnabled: agentDelegationEnabled,
|
|
827
840
|
userMessage: message,
|
|
828
841
|
history,
|
|
@@ -1208,6 +1221,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1208
1221
|
const MAX_AUTO_CONTINUES = 3
|
|
1209
1222
|
const MAX_TRANSIENT_RETRIES = 3
|
|
1210
1223
|
const MAX_REQUIRED_TOOL_CONTINUES = 2
|
|
1224
|
+
const MAX_MEMORY_WRITE_FOLLOWTHROUGHS = 2
|
|
1211
1225
|
let MAX_EXECUTION_FOLLOWTHROUGHS = 1
|
|
1212
1226
|
const MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS = 1
|
|
1213
1227
|
let MAX_ATTACHMENT_FOLLOWTHROUGHS = 1
|
|
@@ -1230,6 +1244,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1230
1244
|
let transientRetryCount = 0
|
|
1231
1245
|
let pendingRetryAfterMs: number | null = null
|
|
1232
1246
|
let requiredToolContinueCount = 0
|
|
1247
|
+
let memoryWriteFollowthroughCount = 0
|
|
1233
1248
|
let executionFollowthroughCount = 0
|
|
1234
1249
|
let executionKickoffFollowthroughCount = 0
|
|
1235
1250
|
let attachmentFollowthroughCount = 0
|
|
@@ -1242,11 +1257,11 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1242
1257
|
&& looksLikeOpenEndedDeliverableTask(message)
|
|
1243
1258
|
const usedToolNames = new Set<string>()
|
|
1244
1259
|
let loopDetectionTriggered: LoopDetectionResult | null = null
|
|
1245
|
-
let terminalToolBoundary: '
|
|
1260
|
+
let terminalToolBoundary: 'durable_wait' | 'context_compaction' | null = null
|
|
1246
1261
|
let terminalToolResponse = ''
|
|
1247
1262
|
|
|
1248
1263
|
try {
|
|
1249
|
-
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
|
|
1264
|
+
const maxIterations = MAX_AUTO_CONTINUES + MAX_TRANSIENT_RETRIES + MAX_REQUIRED_TOOL_CONTINUES + MAX_MEMORY_WRITE_FOLLOWTHROUGHS + MAX_EXECUTION_KICKOFF_FOLLOWTHROUGHS + MAX_EXECUTION_FOLLOWTHROUGHS + MAX_DELIVERABLE_FOLLOWTHROUGHS + MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS + MAX_TOOL_ERROR_FOLLOWTHROUGHS + MAX_TOOL_SUMMARY_RETRIES
|
|
1250
1265
|
for (let iteration = 0; iteration <= maxIterations; iteration++) {
|
|
1251
1266
|
let shouldContinue: ContinuationType = false
|
|
1252
1267
|
let requiredToolReminderNames: string[] = []
|
|
@@ -1526,21 +1541,39 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1526
1541
|
toolOutput: outputStr || '',
|
|
1527
1542
|
})
|
|
1528
1543
|
if (toolBoundary) {
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1544
|
+
if (toolBoundary.kind === 'memory_write') {
|
|
1545
|
+
if (iterationText.trim() || fullText.trim()) {
|
|
1546
|
+
write(`data: ${JSON.stringify({ t: 'reset', text: '' })}\n\n`)
|
|
1547
|
+
}
|
|
1548
|
+
shouldContinue = 'memory_write_followthrough'
|
|
1549
|
+
memoryWriteFollowthroughCount = Math.max(memoryWriteFollowthroughCount, 1)
|
|
1550
|
+
fullText = ''
|
|
1551
|
+
iterationText = ''
|
|
1552
|
+
lastSegment = ''
|
|
1553
|
+
lastSettledSegment = ''
|
|
1554
|
+
needsTextSeparator = false
|
|
1555
|
+
logExecution(session.id, 'decision', 'Successful memory write completed; requesting natural acknowledgement followthrough.', {
|
|
1556
|
+
agentId: session.agentId,
|
|
1557
|
+
detail: { toolName, action: resolveToolAction(event.data?.input) || null, boundary: toolBoundary.kind },
|
|
1558
|
+
})
|
|
1559
|
+
write(`data: ${JSON.stringify({
|
|
1560
|
+
t: 'status',
|
|
1561
|
+
text: JSON.stringify({ terminalToolResult: toolBoundary.kind }),
|
|
1562
|
+
})}\n\n`)
|
|
1563
|
+
break
|
|
1564
|
+
} else {
|
|
1565
|
+
terminalToolBoundary = toolBoundary.kind
|
|
1566
|
+
terminalToolResponse = ''
|
|
1567
|
+
logExecution(session.id, 'decision', `Terminal tool boundary reached: ${toolBoundary.kind}.`, {
|
|
1568
|
+
agentId: session.agentId,
|
|
1569
|
+
detail: { toolName, action: resolveToolAction(event.data?.input) || null, boundary: toolBoundary.kind },
|
|
1570
|
+
})
|
|
1571
|
+
write(`data: ${JSON.stringify({
|
|
1572
|
+
t: 'status',
|
|
1573
|
+
text: JSON.stringify({ terminalToolResult: toolBoundary.kind }),
|
|
1574
|
+
})}\n\n`)
|
|
1575
|
+
break
|
|
1534
1576
|
}
|
|
1535
|
-
logExecution(session.id, 'decision', `Terminal tool boundary reached: ${toolBoundary.kind}.`, {
|
|
1536
|
-
agentId: session.agentId,
|
|
1537
|
-
detail: { toolName, action: resolveToolAction(event.data?.input) || null, boundary: toolBoundary.kind },
|
|
1538
|
-
})
|
|
1539
|
-
write(`data: ${JSON.stringify({
|
|
1540
|
-
t: 'status',
|
|
1541
|
-
text: JSON.stringify({ terminalToolResult: toolBoundary.kind }),
|
|
1542
|
-
})}\n\n`)
|
|
1543
|
-
break
|
|
1544
1577
|
}
|
|
1545
1578
|
if (boundedExternalExecutionTask && getWalletApprovalBoundaryAction(outputStr || '')) {
|
|
1546
1579
|
reachedExecutionBoundary = true
|
|
@@ -1785,6 +1818,23 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1785
1818
|
}
|
|
1786
1819
|
}
|
|
1787
1820
|
|
|
1821
|
+
if (!shouldContinue
|
|
1822
|
+
&& !fullText.trim()
|
|
1823
|
+
&& hasOnlySuccessfulMemoryMutationToolEvents(streamedToolEvents)
|
|
1824
|
+
&& memoryWriteFollowthroughCount < MAX_MEMORY_WRITE_FOLLOWTHROUGHS
|
|
1825
|
+
) {
|
|
1826
|
+
shouldContinue = 'memory_write_followthrough'
|
|
1827
|
+
memoryWriteFollowthroughCount++
|
|
1828
|
+
write(`data: ${JSON.stringify({
|
|
1829
|
+
t: 'status',
|
|
1830
|
+
text: JSON.stringify({
|
|
1831
|
+
memoryWriteFollowthrough: memoryWriteFollowthroughCount,
|
|
1832
|
+
maxFollowthroughs: MAX_MEMORY_WRITE_FOLLOWTHROUGHS,
|
|
1833
|
+
reason: 'empty_reply_after_memory_write',
|
|
1834
|
+
}),
|
|
1835
|
+
})}\n\n`)
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1788
1838
|
if (!shouldContinue
|
|
1789
1839
|
&& attachmentFollowthroughCount < MAX_ATTACHMENT_FOLLOWTHROUGHS
|
|
1790
1840
|
&& shouldForceAttachmentFollowthrough({
|
|
@@ -1959,10 +2009,12 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
1959
2009
|
|
|
1960
2010
|
if (!shouldContinue) break
|
|
1961
2011
|
|
|
1962
|
-
const continuationAssistantText =
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2012
|
+
const continuationAssistantText = shouldContinue === 'memory_write_followthrough'
|
|
2013
|
+
? ''
|
|
2014
|
+
: resolveContinuationAssistantText({
|
|
2015
|
+
iterationText,
|
|
2016
|
+
lastSegment,
|
|
2017
|
+
})
|
|
1966
2018
|
|
|
1967
2019
|
const continuationPrompt = buildContinuationPrompt({
|
|
1968
2020
|
type: shouldContinue,
|
|
@@ -2074,7 +2126,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
2074
2126
|
}
|
|
2075
2127
|
await emitLlmOutputHook(finalResponse)
|
|
2076
2128
|
await cleanup()
|
|
2077
|
-
return { fullText, finalResponse }
|
|
2129
|
+
return { fullText, finalResponse, toolEvents: streamedToolEvents }
|
|
2078
2130
|
}
|
|
2079
2131
|
|
|
2080
2132
|
// Extract LLM-generated suggestions from the response and strip the tag
|
|
@@ -2168,5 +2220,5 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
|
|
|
2168
2220
|
// Clean up browser and other session resources
|
|
2169
2221
|
await cleanup()
|
|
2170
2222
|
|
|
2171
|
-
return { fullText, finalResponse }
|
|
2223
|
+
return { fullText, finalResponse, toolEvents: streamedToolEvents }
|
|
2172
2224
|
}
|
|
@@ -10,12 +10,14 @@ import os from 'node:os'
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import type { MessageToolEvent } from '@/types'
|
|
12
12
|
import { extractSuggestions } from '@/lib/server/suggestions'
|
|
13
|
+
import { isSuccessfulMemoryMutationToolEvent } from '@/lib/server/chat-execution/memory-mutation-tools'
|
|
13
14
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
// Types
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
|
|
18
19
|
export type ContinuationType =
|
|
20
|
+
| 'memory_write_followthrough'
|
|
19
21
|
| 'recursion'
|
|
20
22
|
| 'transient'
|
|
21
23
|
| 'required_tool'
|
|
@@ -584,6 +586,17 @@ function buildRequiredToolPrompt(params: {
|
|
|
584
586
|
return `You have not yet completed the required explicit tool step(s): ${params.requiredToolReminderNames.join(', ')}. Use those enabled tools now before declaring success. Do not replace ask_human with a plain-text request, do not replace outbound delivery tools with prose, and do not replace screenshot requests with text-only summaries.`
|
|
585
587
|
}
|
|
586
588
|
|
|
589
|
+
function buildMemoryWriteFollowthroughPrompt(): string {
|
|
590
|
+
return [
|
|
591
|
+
'The memory write already succeeded.',
|
|
592
|
+
'Do not repeat the raw tool output, memory ID, category, or any "stored memory" wording.',
|
|
593
|
+
'Do not call another memory, web, or history tool unless the user explicitly asked you to verify.',
|
|
594
|
+
'Do not answer with NO_MESSAGE or HEARTBEAT_OK.',
|
|
595
|
+
'Reply naturally in one short sentence that acknowledges what changed.',
|
|
596
|
+
'If the stored memory was a name, nickname, or reply-medium preference, immediately use that preference in the acknowledgement itself.',
|
|
597
|
+
].join('\n')
|
|
598
|
+
}
|
|
599
|
+
|
|
587
600
|
// ---------------------------------------------------------------------------
|
|
588
601
|
// buildContinuationPrompt — unified prompt builder for all continuation types
|
|
589
602
|
// ---------------------------------------------------------------------------
|
|
@@ -601,6 +614,9 @@ export function buildContinuationPrompt(params: {
|
|
|
601
614
|
cwd?: string
|
|
602
615
|
}): string | null {
|
|
603
616
|
switch (params.type) {
|
|
617
|
+
case 'memory_write_followthrough':
|
|
618
|
+
return buildMemoryWriteFollowthroughPrompt()
|
|
619
|
+
|
|
604
620
|
case 'recursion':
|
|
605
621
|
return 'Continue where you left off. Complete the remaining steps of the objective.'
|
|
606
622
|
|
|
@@ -674,6 +690,7 @@ function resolveToolOnlyFinalResponse(toolEvents: MessageToolEvent[] | undefined
|
|
|
674
690
|
const events = Array.isArray(toolEvents) ? toolEvents : []
|
|
675
691
|
for (let index = events.length - 1; index >= 0; index--) {
|
|
676
692
|
const event = events[index]
|
|
693
|
+
if (isSuccessfulMemoryMutationToolEvent(event)) continue
|
|
677
694
|
const output = typeof event?.output === 'string'
|
|
678
695
|
? extractSuggestions(event.output).clean.trim()
|
|
679
696
|
: ''
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Agent, MemoryEntry, Session } from '@/types'
|
|
2
|
+
import { dedup } from '@/lib/shared-utils'
|
|
3
|
+
import { getMemoryDb } from '@/lib/server/memory/memory-db'
|
|
4
|
+
import type { InboundMessage } from './types'
|
|
5
|
+
|
|
6
|
+
const SENDER_PREFERENCE_CATEGORIES = new Set([
|
|
7
|
+
'identity/preferences',
|
|
8
|
+
'identity/contacts',
|
|
9
|
+
'identity/relationships',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
function summaryForPrompt(entry: MemoryEntry): string {
|
|
13
|
+
const title = String(entry.title || '').trim()
|
|
14
|
+
const firstLine = String(entry.content || '').split('\n').find((line) => line.trim())?.trim() || ''
|
|
15
|
+
const summary = [title, firstLine].filter(Boolean).join(': ')
|
|
16
|
+
if (!summary) return ''
|
|
17
|
+
return summary.length > 220 ? `${summary.slice(0, 217)}...` : summary
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function connectorPreferenceMetadata(entry: MemoryEntry): Record<string, unknown> | null {
|
|
21
|
+
const metadata = entry.metadata as Record<string, unknown> | undefined
|
|
22
|
+
const direct = metadata?.connectorPreference
|
|
23
|
+
if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
|
|
24
|
+
return direct as Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
return metadata && typeof metadata === 'object' && !Array.isArray(metadata)
|
|
27
|
+
? metadata
|
|
28
|
+
: null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function preferredDisplayNameFromMetadata(entry: MemoryEntry): string | null {
|
|
32
|
+
const metadata = connectorPreferenceMetadata(entry)
|
|
33
|
+
const value = typeof metadata?.preferredDisplayName === 'string' ? metadata.preferredDisplayName.trim() : ''
|
|
34
|
+
return value || null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function preferredReplyMediumFromMetadata(entry: MemoryEntry): 'voice_note' | null {
|
|
38
|
+
const metadata = connectorPreferenceMetadata(entry)
|
|
39
|
+
return metadata?.preferredReplyMedium === 'voice_note' ? 'voice_note' : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listSenderPreferenceMemories(params: {
|
|
43
|
+
agent?: Partial<Agent> | null
|
|
44
|
+
session?: Partial<Session> | null
|
|
45
|
+
}): MemoryEntry[] {
|
|
46
|
+
const agentId = typeof params.agent?.id === 'string' ? params.agent.id.trim() : ''
|
|
47
|
+
const sessionId = typeof params.session?.id === 'string' ? params.session.id.trim() : ''
|
|
48
|
+
if (!agentId || !sessionId) return []
|
|
49
|
+
const memDb = getMemoryDb()
|
|
50
|
+
return memDb.list(agentId, 250)
|
|
51
|
+
.filter((entry) => entry.sessionId === sessionId)
|
|
52
|
+
.filter((entry) => SENDER_PREFERENCE_CATEGORIES.has(String(entry.category || '').trim()))
|
|
53
|
+
.sort((a, b) => (a.updatedAt || a.createdAt || 0) - (b.updatedAt || b.createdAt || 0))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ResolvedSenderPreferencePolicy {
|
|
57
|
+
preferredDisplayName: string | null
|
|
58
|
+
preferredReplyMedium: 'voice_note' | null
|
|
59
|
+
styleInstructions: string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveSenderPreferencePolicy(params: {
|
|
63
|
+
agent?: Partial<Agent> | null
|
|
64
|
+
session?: Partial<Session> | null
|
|
65
|
+
msg: InboundMessage
|
|
66
|
+
}): ResolvedSenderPreferencePolicy {
|
|
67
|
+
if (params.msg.isGroup) {
|
|
68
|
+
return {
|
|
69
|
+
preferredDisplayName: null,
|
|
70
|
+
preferredReplyMedium: null,
|
|
71
|
+
styleInstructions: [],
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const memories = listSenderPreferenceMemories(params)
|
|
76
|
+
let preferredDisplayName: string | null = null
|
|
77
|
+
let preferredReplyMedium: 'voice_note' | null = null
|
|
78
|
+
const styleInstructions: string[] = []
|
|
79
|
+
|
|
80
|
+
for (const entry of memories) {
|
|
81
|
+
const displayName = preferredDisplayNameFromMetadata(entry)
|
|
82
|
+
if (displayName) preferredDisplayName = displayName
|
|
83
|
+
|
|
84
|
+
const replyMedium = preferredReplyMediumFromMetadata(entry)
|
|
85
|
+
if (replyMedium) preferredReplyMedium = replyMedium
|
|
86
|
+
|
|
87
|
+
const summary = summaryForPrompt(entry)
|
|
88
|
+
if (summary) styleInstructions.push(summary)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
preferredDisplayName,
|
|
93
|
+
preferredReplyMedium,
|
|
94
|
+
styleInstructions: dedup(styleInstructions).slice(-4),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildSenderPreferenceContextBlock(
|
|
99
|
+
policy: ResolvedSenderPreferencePolicy,
|
|
100
|
+
senderLabel: string,
|
|
101
|
+
): string {
|
|
102
|
+
const lines: string[] = []
|
|
103
|
+
if (policy.preferredDisplayName) {
|
|
104
|
+
lines.push(`- Address this sender as "${policy.preferredDisplayName}".`)
|
|
105
|
+
}
|
|
106
|
+
if (policy.preferredReplyMedium === 'voice_note') {
|
|
107
|
+
lines.push('- Reply with a voice note in this direct chat unless one has already been sent this turn.')
|
|
108
|
+
}
|
|
109
|
+
for (const instruction of policy.styleInstructions) {
|
|
110
|
+
lines.push(`- ${instruction}`)
|
|
111
|
+
}
|
|
112
|
+
if (lines.length === 0) return ''
|
|
113
|
+
return [
|
|
114
|
+
'## Sender Preferences',
|
|
115
|
+
`These stored preferences apply to the current sender "${senderLabel}":`,
|
|
116
|
+
...lines,
|
|
117
|
+
].join('\n')
|
|
118
|
+
}
|