@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.
Files changed (26) hide show
  1. package/README.md +5 -5
  2. package/package.json +1 -1
  3. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +24 -0
  4. package/src/lib/server/chat-execution/chat-execution-utils.ts +5 -2
  5. package/src/lib/server/chat-execution/chat-execution.ts +5 -2
  6. package/src/lib/server/chat-execution/chat-streaming-utils.ts +4 -38
  7. package/src/lib/server/chat-execution/memory-mutation-tools.ts +53 -0
  8. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +18 -3
  9. package/src/lib/server/chat-execution/stream-agent-chat.ts +76 -24
  10. package/src/lib/server/chat-execution/stream-continuation.ts +17 -0
  11. package/src/lib/server/connectors/contact-preferences.ts +118 -0
  12. package/src/lib/server/connectors/manager-roundtrip.test.ts +8 -2
  13. package/src/lib/server/connectors/manager.test.ts +231 -9
  14. package/src/lib/server/connectors/manager.ts +218 -72
  15. package/src/lib/server/connectors/types.ts +2 -0
  16. package/src/lib/server/connectors/voice-note.ts +80 -0
  17. package/src/lib/server/runtime/heartbeat-wake.test.ts +2 -2
  18. package/src/lib/server/runtime/heartbeat-wake.ts +8 -4
  19. package/src/lib/server/runtime/queue-reconcile.test.ts +262 -2
  20. package/src/lib/server/runtime/queue.ts +126 -226
  21. package/src/lib/server/runtime/scheduler.test.ts +126 -0
  22. package/src/lib/server/runtime/scheduler.ts +5 -6
  23. package/src/lib/server/runtime/session-run-manager.ts +2 -2
  24. package/src/lib/server/session-tools/connector.ts +24 -43
  25. package/src/lib/server/session-tools/memory.ts +38 -0
  26. 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.8 Highlights
20
+ ### v1.0.9 Highlights
21
21
 
22
- - **Learned skills and self-healing**: SwarmClaw now keeps agent-scoped learned skills and shadow revisions so repeated successes and repeated external integration failures can harden into reusable local behavior without silently mutating the shared skill library.
23
- - **Direct chat stability**: chat-origin runs now stop after the visible answer instead of enqueueing hidden follow-up loops, leaked control tokens such as `NO_MESSAGE` stay out of the user transcript, and repeated internal reruns no longer replace the reply the user already saw.
24
- - **OpenClaw and Ollama route hardening**: agent thread sessions now repair stale credential/endpoint resolution more aggressively, including Ollama Cloud vs local endpoint selection and OpenClaw gateway fallback behavior.
25
- - **Operator UX fixes**: new agents appear in the list immediately, gateway-disconnected chat CTAs now route to the current agent's settings instead of global settings, setup-wizard flicker after access-key login is gone, and screenshot-heavy tool runs no longer render duplicate previews in chat.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -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 && heartbeatConfig?.deliveryMode === 'tool_only' && !isDirectConnectorSession(session))
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('falls back to the latest meaningful tool result when tool calls finished without prose', () => {
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
- { name: 'memory_tool', input: '', output: 'Stored memory "Project Kodiak details" (id: abc123).' } as MessageToolEvent,
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, 'Stored memory "Project Kodiak details" (id: abc123).')
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: 'memory_write' | 'durable_wait' | 'context_compaction' | null = null
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
- terminalToolBoundary = toolBoundary.kind
1530
- terminalToolResponse = 'responseText' in toolBoundary ? (toolBoundary.responseText || '') : ''
1531
- if (terminalToolResponse) {
1532
- lastSegment = terminalToolResponse
1533
- lastSettledSegment = terminalToolResponse
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 = resolveContinuationAssistantText({
1963
- iterationText,
1964
- lastSegment,
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
+ }