@swarmclawai/swarmclaw 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/app/agents/page.tsx +2 -1
  3. package/src/app/globals.css +28 -0
  4. package/src/app/home/page.tsx +11 -0
  5. package/src/app/settings/page.tsx +12 -5
  6. package/src/components/connectors/connector-list.tsx +2 -5
  7. package/src/components/logs/log-list.tsx +2 -5
  8. package/src/components/providers/provider-list.tsx +2 -5
  9. package/src/components/runs/run-list.tsx +2 -6
  10. package/src/components/schedules/schedule-list.tsx +7 -1
  11. package/src/components/ui/full-screen-loader.tsx +0 -29
  12. package/src/components/ui/page-loader.tsx +69 -0
  13. package/src/lib/runtime/runtime-loop.ts +21 -1
  14. package/src/lib/server/chat-execution/chat-execution-utils.test.ts +56 -0
  15. package/src/lib/server/chat-execution/chat-execution-utils.ts +24 -0
  16. package/src/lib/server/chat-execution/chat-execution.ts +43 -4
  17. package/src/lib/server/chat-execution/chat-streaming-utils.ts +1 -38
  18. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +2 -46
  19. package/src/lib/server/chat-execution/stream-agent-chat.ts +51 -86
  20. package/src/lib/server/chat-execution/stream-continuation.ts +1 -1
  21. package/src/lib/server/connectors/manager.ts +1 -1
  22. package/src/lib/server/memory/memory-policy.test.ts +5 -15
  23. package/src/lib/server/memory/memory-policy.ts +11 -41
  24. package/src/lib/server/runtime/heartbeat-service.test.ts +46 -0
  25. package/src/lib/server/runtime/heartbeat-service.ts +5 -1
  26. package/src/lib/server/runtime/runtime-settings.test.ts +4 -4
  27. package/src/lib/server/runtime/runtime-settings.ts +4 -0
  28. package/src/lib/server/runtime/session-run-manager.ts +2 -0
  29. package/src/lib/server/session-tools/delegate.ts +3 -3
  30. package/src/lib/server/session-tools/memory.ts +220 -48
  31. package/src/types/index.ts +4 -0
  32. package/src/views/settings/section-runtime-loop.tsx +38 -0
@@ -1,14 +1,11 @@
1
1
  import type { MessageToolEvent } from '@/types'
2
2
  import { canonicalizePluginId } from '@/lib/server/tool-aliases'
3
3
  import { extractSuggestions } from '@/lib/server/suggestions'
4
- import { isDirectMemoryWriteRequest } from '@/lib/server/memory/memory-policy'
5
4
  import {
6
- isBroadGoal,
7
5
  looksLikeExternalWalletTask,
8
- looksLikeOpenEndedDeliverableTask,
9
6
  } from '@/lib/server/chat-execution/stream-continuation'
10
7
 
11
- const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export)\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
8
+ 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
12
9
 
13
10
  export function isLikelyToolErrorOutput(output: string): boolean {
14
11
  const trimmed = String(output || '').trim()
@@ -168,37 +165,3 @@ export function compactThreadRecallText(text: string, maxChars = 180): string {
168
165
  return compact.length > maxChars ? `${compact.slice(0, maxChars - 3)}...` : compact
169
166
  }
170
167
 
171
- const DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE = /\b(?:then|and then|after that)?\s*(?:confirm|recap|repeat|summarize|tell me|say)\b[\s\S]{0,120}\b(?:stored|saved|updated|remembered|wrote|write)\b/i
172
- const DIRECT_MEMORY_WRITE_EXTRA_ACTION_RE = /\b(?:then|and then|after that|also)\b[\s\S]{0,160}\b(?:write|create|send|email|message|delegate|research|search|browse|open|edit|build|schedule|plan|review|analy[sz]e)\b/i
173
-
174
- export function isNarrowDirectMemoryWriteTurn(message: string): boolean {
175
- const trimmed = String(message || '').trim()
176
- if (!trimmed || !isDirectMemoryWriteRequest(trimmed)) return false
177
- if (looksLikeOpenEndedDeliverableTask(trimmed)) return false
178
- if (DIRECT_MEMORY_WRITE_EXTRA_ACTION_RE.test(trimmed) && !DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE.test(trimmed)) {
179
- return false
180
- }
181
- return !isBroadGoal(trimmed) || DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE.test(trimmed) || !/[?]$/.test(trimmed)
182
- }
183
-
184
- const CURRENT_THREAD_RECALL_BLOCKED_TOOL_IDS = new Set([
185
- 'memory',
186
- 'manage_sessions',
187
- 'web',
188
- 'context_mgmt',
189
- ])
190
-
191
- export function shouldAllowToolForCurrentThreadRecall(toolName: string): boolean {
192
- const canonicalToolName = canonicalizePluginId(toolName) || toolName.trim().toLowerCase()
193
- return !CURRENT_THREAD_RECALL_BLOCKED_TOOL_IDS.has(canonicalToolName)
194
- }
195
-
196
- const DIRECT_MEMORY_WRITE_ALLOWED_TOOL_IDS = new Set([
197
- 'memory_store',
198
- 'memory_update',
199
- ])
200
-
201
- export function shouldAllowToolForDirectMemoryWrite(toolName: string): boolean {
202
- const rawToolName = toolName.trim().toLowerCase()
203
- return DIRECT_MEMORY_WRITE_ALLOWED_TOOL_IDS.has(rawToolName)
204
- }
@@ -8,13 +8,10 @@ import {
8
8
  buildExternalWalletExecutionBlock,
9
9
  buildToolDisciplineLines,
10
10
  getExplicitRequiredToolNames,
11
- isNarrowDirectMemoryWriteTurn,
12
11
  isWalletSimulationResult,
13
12
  looksLikeOpenEndedDeliverableTask,
14
13
  resolveContinuationAssistantText,
15
14
  resolveFinalStreamResponseText,
16
- shouldAllowToolForDirectMemoryWrite,
17
- shouldAllowToolForCurrentThreadRecall,
18
15
  shouldForceAttachmentFollowthrough,
19
16
  shouldForceRecoverableToolErrorFollowthrough,
20
17
  shouldTerminateOnSuccessfulMemoryMutation,
@@ -208,57 +205,16 @@ describe('buildToolDisciplineLines', () => {
208
205
  assert.ok(streamAgentChatSource.includes('did not start the required workspace tool step'))
209
206
  })
210
207
 
211
- it('adds a dedicated current-thread recall block and removes long-term memory tools for those turns', () => {
208
+ it('adds current-thread recall guidance and immediate memory routes in the system prompt', () => {
212
209
  assert.ok(streamAgentChatSource.includes('## Current Thread Recall'))
213
210
  assert.ok(streamAgentChatSource.includes('## Immediate Memory Routes'))
214
- assert.ok(streamAgentChatSource.includes('## Direct Memory Write'))
215
211
  assert.ok(streamAgentChatSource.includes('call `memory_store` or `memory_update` immediately before any planning, delegation, task creation, or agent management'))
216
- assert.ok(streamAgentChatSource.includes('Do not inspect skills, browse the workspace, request capabilities, manage tasks, manage agents, or delegate before the direct memory write is complete.'))
217
212
  assert.ok(streamAgentChatSource.includes('Do NOT call memory tools, web search, or session-history tools'))
218
- assert.ok(streamAgentChatSource.includes('const currentThreadRecallRequest = !directMemoryWriteOnlyTurn && isCurrentThreadRecallRequest(message)'))
219
- assert.ok(streamAgentChatSource.includes('const directMemoryWriteOnlyTurn = isNarrowDirectMemoryWriteTurn(message)'))
220
- assert.ok(streamAgentChatSource.includes('shouldAllowToolForDirectMemoryWrite(toolName)'))
221
- assert.ok(streamAgentChatSource.includes('shouldAllowToolForCurrentThreadRecall(toolName)'))
213
+ assert.ok(streamAgentChatSource.includes('const currentThreadRecallRequest = isCurrentThreadRecallRequest(message)'))
222
214
  assert.ok(streamSources.includes('Preserve hard structural constraints from the original request'))
223
215
  assert.ok(streamAgentChatSource.includes('## Exact Structural Constraints'))
224
216
  })
225
217
 
226
- it('blocks memory, session-history, web, and context tools during same-thread recall turns', () => {
227
- assert.equal(shouldAllowToolForCurrentThreadRecall('memory_tool'), false)
228
- assert.equal(shouldAllowToolForCurrentThreadRecall('memory_search'), false)
229
- assert.equal(shouldAllowToolForCurrentThreadRecall('memory_get'), false)
230
- assert.equal(shouldAllowToolForCurrentThreadRecall('memory_store'), false)
231
- assert.equal(shouldAllowToolForCurrentThreadRecall('memory_update'), false)
232
- assert.equal(shouldAllowToolForCurrentThreadRecall('search_history_tool'), false)
233
- assert.equal(shouldAllowToolForCurrentThreadRecall('sessions_tool'), false)
234
- assert.equal(shouldAllowToolForCurrentThreadRecall('web_search'), false)
235
- assert.equal(shouldAllowToolForCurrentThreadRecall('context_status'), false)
236
- assert.equal(shouldAllowToolForCurrentThreadRecall('files'), true)
237
- })
238
-
239
- it('only allows direct memory write tools during pure remember/store turns', () => {
240
- assert.equal(shouldAllowToolForDirectMemoryWrite('memory_store'), true)
241
- assert.equal(shouldAllowToolForDirectMemoryWrite('memory_update'), true)
242
- assert.equal(shouldAllowToolForDirectMemoryWrite('memory_tool'), false)
243
- assert.equal(shouldAllowToolForDirectMemoryWrite('manage_capabilities'), false)
244
- assert.equal(shouldAllowToolForDirectMemoryWrite('files'), false)
245
- })
246
-
247
- it('treats long remember-and-confirm turns as narrow direct memory writes', () => {
248
- assert.equal(
249
- isNarrowDirectMemoryWriteTurn('Remember that my favorite programming language is Rust and I prefer functional programming patterns. Then confirm what you just stored.'),
250
- true,
251
- )
252
- assert.equal(
253
- isNarrowDirectMemoryWriteTurn('Remember these facts for future conversations: My favorite programming language is Rust. My deploy target is Fly.io. My team size is 7 people. The project is codenamed "Neptune".'),
254
- true,
255
- )
256
- assert.equal(
257
- isNarrowDirectMemoryWriteTurn('Remember that my favorite programming language is Rust, then write a file summarizing it and send it to me.'),
258
- false,
259
- )
260
- })
261
-
262
218
  it('canonicalizes required tool names when checking completion', () => {
263
219
  // The requiredToolsPending filter must canonicalize tool names so that
264
220
  // alias names (e.g. ask_human) match canonical names from LangGraph events.
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import { HumanMessage, AIMessage } from '@langchain/core/messages'
3
3
  import { createReactAgent } from '@langchain/langgraph/prebuilt'
4
+ import { MemorySaver } from '@langchain/langgraph'
4
5
  import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/runtime/heartbeat-defaults'
5
6
  import { buildSessionTools } from '@/lib/server/session-tools'
6
7
  import { buildChatModel } from '@/lib/server/build-llm'
@@ -51,31 +52,43 @@ import {
51
52
  import type { ContinuationType } from '@/lib/server/chat-execution/stream-continuation'
52
53
  import { dedup, errorMessage, sleep } from '@/lib/shared-utils'
53
54
  import { perf } from '@/lib/server/runtime/perf'
54
- import { getCheckpointSaver } from '@/lib/server/langgraph-checkpoint'
55
55
  import {
56
56
  compactThreadRecallText,
57
57
  getExplicitRequiredToolNames,
58
58
  getWalletApprovalBoundaryAction,
59
- isNarrowDirectMemoryWriteTurn,
60
59
  isWalletSimulationResult,
61
60
  resolveToolAction,
62
- shouldAllowToolForDirectMemoryWrite,
63
- shouldAllowToolForCurrentThreadRecall,
64
61
  shouldForceExternalServiceSummary,
65
62
  shouldTerminateOnSuccessfulMemoryMutation,
66
63
  updateStreamedToolEvents,
67
64
  } from '@/lib/server/chat-execution/chat-streaming-utils'
68
65
  import { LangGraphToolEventTracker } from '@/lib/server/chat-execution/tool-event-tracker'
69
66
 
67
+ // LangGraph's streamEvents leaves dangling internal promises when the for-await
68
+ // loop exits early (break on tool loop detection, execution boundary, etc.).
69
+ // These promises may later reject with GraphRecursionError or AbortError.
70
+ // Register a permanent handler to prevent process crashes from these expected
71
+ // background rejections. Only LangGraph-specific errors (identified by
72
+ // pregelTaskId or lc_error_code) are suppressed; all other rejections propagate
73
+ // normally.
74
+ process.on('unhandledRejection', (err: unknown) => {
75
+ if (
76
+ err && typeof err === 'object'
77
+ && ('pregelTaskId' in err
78
+ || (err instanceof Error && (err.name === 'AbortError' || err.name === 'GraphRecursionError'))
79
+ || (err as Record<string, unknown>).lc_error_code === 'GRAPH_RECURSION_LIMIT')
80
+ ) {
81
+ // Silently suppress — expected background rejection from LangGraph
82
+ return
83
+ }
84
+ })
85
+
70
86
  // Re-export continuation functions so existing consumers don't need to change imports
71
87
  export {
72
88
  getExplicitRequiredToolNames,
73
- isNarrowDirectMemoryWriteTurn,
74
89
  isWalletSimulationResult,
75
90
  looksLikeOpenEndedDeliverableTask,
76
91
  shouldForceRecoverableToolErrorFollowthrough,
77
- shouldAllowToolForDirectMemoryWrite,
78
- shouldAllowToolForCurrentThreadRecall,
79
92
  shouldForceExternalExecutionFollowthrough,
80
93
  shouldForceDeliverableFollowthrough,
81
94
  shouldForceExternalServiceSummary,
@@ -101,9 +114,6 @@ interface StreamAgentChatOpts {
101
114
 
102
115
  // LangGraph uses this internal configurable key to bypass subgraph lookup when
103
116
  // resolving state from a namespaced checkpoint. It is not exported publicly in
104
- // @langchain/langgraph 1.x, so keep the literal here instead of importing a
105
- // non-exported symbol that breaks Next compilation.
106
- const LANGGRAPH_CHECKPOINTER_CONFIG_KEY = '__pregel_checkpointer'
107
117
  const CONTEXT_WARNING_OVERHEAD_TOKENS = 192
108
118
 
109
119
  /** Extract HTTP status code and Retry-After from provider error objects (OpenAI SDK, etc.) */
@@ -341,7 +351,6 @@ function buildAgenticExecutionPolicy(opts: {
341
351
  const pluginLines = buildPluginCapabilityLines(opts.enabledPlugins, { platformAssignScope: opts.platformAssignScope })
342
352
  const toolDisciplineLines = buildToolDisciplineLines(opts.enabledPlugins)
343
353
  const hasMemoryTools = opts.enabledPlugins.some((toolId) => (canonicalizePluginId(toolId) || toolId) === 'memory')
344
- const directMemoryWriteOnlyTurn = Boolean(opts.userMessage && isNarrowDirectMemoryWriteTurn(opts.userMessage))
345
354
 
346
355
  const parts: string[] = []
347
356
 
@@ -371,10 +380,6 @@ function buildAgenticExecutionPolicy(opts: {
371
380
  'Do not use `manage_tasks`, `manage_agents`, or `delegate` as a substitute for a direct memory write or recall step.',
372
381
  )
373
382
  }
374
- if (hasMemoryTools && directMemoryWriteOnlyTurn) {
375
- parts.push(buildDirectMemoryWriteBlock())
376
- }
377
-
378
383
  if (opts.hasAttachmentContext) {
379
384
  parts.push(
380
385
  '## Attachments',
@@ -393,6 +398,7 @@ function buildAgenticExecutionPolicy(opts: {
393
398
  'Execute by default — only confirm on high-risk actions.',
394
399
  'If a tool errors, retry or explain the blocker. Never claim success without evidence.',
395
400
  'Keep responses concise. Bullet points over prose. After file operations, confirm the result briefly (path and status) without echoing the full file contents.',
401
+ 'Do not end every reply with a question. Only ask when a specific missing detail blocks progress. When a task is done, state the result and stop.',
396
402
  opts.responseStyle === 'concise'
397
403
  ? `IMPORTANT: Be extremely concise.${opts.responseMaxChars ? ` Keep responses under ${opts.responseMaxChars} characters.` : ' Target under 500 characters.'} Lead with the answer, skip preamble.`
398
404
  : opts.responseStyle === 'detailed'
@@ -458,16 +464,6 @@ function buildCurrentThreadRecallBlock(history: Message[]): string {
458
464
  return lines.join('\n')
459
465
  }
460
466
 
461
- function buildDirectMemoryWriteBlock(): string {
462
- return [
463
- '## Direct Memory Write',
464
- 'This turn is a direct request to remember, store, or correct a durable fact.',
465
- 'Call `memory_store` or `memory_update` immediately, then confirm the stored value succinctly.',
466
- 'If the user bundled several related facts into one remember request, store them together in one canonical memory write unless they explicitly asked for separate entries.',
467
- 'Do not inspect skills, browse the workspace, request capabilities, manage tasks, manage agents, or delegate before the direct memory write is complete.',
468
- ].join('\n')
469
- }
470
-
471
467
  export interface StreamAgentChatResult {
472
468
  /** All text accumulated across every LLM turn (for SSE / web UI history). */
473
469
  fullText: string
@@ -543,8 +539,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
543
539
 
544
540
  const promptParts: string[] = []
545
541
  const hasProvidedSystemPrompt = typeof systemPrompt === 'string' && systemPrompt.trim().length > 0
546
- const directMemoryWriteOnlyTurn = isNarrowDirectMemoryWriteTurn(message)
547
- const currentThreadRecallRequest = !directMemoryWriteOnlyTurn && isCurrentThreadRecallRequest(message)
542
+ const currentThreadRecallRequest = isCurrentThreadRecallRequest(message)
548
543
  const hasAttachmentContext = Boolean(
549
544
  imagePath
550
545
  || attachedFiles?.length
@@ -653,8 +648,8 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
653
648
  try {
654
649
  const pluginContextParts = await getPluginManager().collectAgentContext(session, sessionPlugins, message, history)
655
650
  promptParts.push(...pluginContextParts)
656
- } catch {
657
- // Plugin context injection is non-critical
651
+ } catch (err: unknown) {
652
+ console.error('[stream-agent-chat] Plugin context injection failed:', err instanceof Error ? err.message : String(err))
658
653
  }
659
654
 
660
655
  if (!hasProvidedSystemPrompt && activeProjectContext.projectId) {
@@ -757,7 +752,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
757
752
 
758
753
  // Proactive memory recall: inject relevant memories into context before LLM invocation
759
754
  // Skips heartbeat polls, very short messages, and thread-recall requests (which use chat history instead)
760
- if (session.agentId && !directMemoryWriteOnlyTurn && !currentThreadRecallRequest && message.length > 12) {
755
+ if (session.agentId && !currentThreadRecallRequest && message.length > 12) {
761
756
  try {
762
757
  const agents = loadAgents()
763
758
  const agentForMemory = agents[session.agentId]
@@ -794,23 +789,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
794
789
  memoryScopeMode: agentMemoryScopeMode,
795
790
  })
796
791
  endToolBuildPerf({ toolCount: tools.length })
797
- const toolsForTurn = currentThreadRecallRequest
798
- ? tools.filter((tool) => {
799
- const toolName = typeof (tool as { name?: unknown }).name === 'string'
800
- ? String((tool as { name?: unknown }).name)
801
- : ''
802
- return shouldAllowToolForCurrentThreadRecall(toolName)
803
- })
804
- : directMemoryWriteOnlyTurn
805
- ? tools.filter((tool) => {
806
- const toolName = typeof (tool as { name?: unknown }).name === 'string'
807
- ? String((tool as { name?: unknown }).name)
808
- : ''
809
- return shouldAllowToolForDirectMemoryWrite(toolName)
810
- })
811
- : tools
812
- const checkpointNamespace = `chat:${startTs}`
813
- const checkpointSaver = getCheckpointSaver()
814
792
  const recursionLimit = getAgentLoopRecursionLimit(runtime)
815
793
 
816
794
  // Build message history for context
@@ -997,11 +975,17 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
997
975
  // Warning failure is non-critical
998
976
  }
999
977
 
978
+ // Use a fresh in-memory checkpointer instead of the SQLite one. We manage
979
+ // conversation history externally via langchainMessages — each iteration
980
+ // receives full history, so no cross-iteration checkpoint state is needed.
981
+ // MemorySaver avoids the SQLite serde round-trip that dropped tool_call IDs
982
+ // or ToolMessages, causing OpenAI to reject with "tool_calls must be
983
+ // followed by tool messages" errors.
1000
984
  const agent = createReactAgent({
1001
985
  llm,
1002
- tools: toolsForTurn,
986
+ tools,
1003
987
  prompt,
1004
- checkpointer: checkpointSaver,
988
+ checkpointer: new MemorySaver(),
1005
989
  })
1006
990
 
1007
991
  const langchainMessages: Array<HumanMessage | AIMessage> = []
@@ -1018,7 +1002,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1018
1002
  const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
1019
1003
  langchainMessages.push(new HumanMessage({ content: currentContent }))
1020
1004
  let pendingGraphMessages = [...langchainMessages]
1021
- let currentCheckpointId: string | undefined
1022
1005
 
1023
1006
  let fullText = ''
1024
1007
  let lastSegment = ''
@@ -1073,7 +1056,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1073
1056
  MAX_TOOL_SUMMARY_RETRIES = 1
1074
1057
  MAX_UNFINISHED_TOOL_FOLLOWTHROUGHS = 1
1075
1058
  }
1076
- const REQUIRED_TOOL_KICKOFF_TIMEOUT_MS = 20_000
1059
+ const REQUIRED_TOOL_KICKOFF_TIMEOUT_MS = runtime.requiredToolKickoffMs
1077
1060
  let autoContinueCount = 0
1078
1061
  let transientRetryCount = 0
1079
1062
  let pendingRetryAfterMs: number | null = null
@@ -1154,7 +1137,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1154
1137
  idleTimer = setTimeout(() => {
1155
1138
  idleTimedOut = true
1156
1139
  iterationController.abort()
1157
- }, 90_000)
1140
+ }, runtime.streamIdleStallMs)
1158
1141
  }
1159
1142
 
1160
1143
  const armRequiredToolKickoff = () => {
@@ -1175,23 +1158,21 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1175
1158
  const toolPerfEnds = new Map<string, (extra?: Record<string, unknown>) => number>()
1176
1159
  const iterationInputMessages = pendingGraphMessages
1177
1160
  let iterationSucceeded = false
1161
+ const eventStream = agent.streamEvents(
1162
+ { messages: iterationInputMessages },
1163
+ {
1164
+ version: 'v2',
1165
+ recursionLimit,
1166
+ signal: iterationController.signal,
1167
+ configurable: {
1168
+ thread_id: `${session.id}:${startTs}:${iteration}`,
1169
+ },
1170
+ },
1171
+ )
1178
1172
 
1179
1173
  try {
1180
1174
  armIdleWatchdog()
1181
1175
  armRequiredToolKickoff()
1182
- const eventStream = agent.streamEvents(
1183
- { messages: iterationInputMessages },
1184
- {
1185
- version: 'v2',
1186
- recursionLimit,
1187
- signal: iterationController.signal,
1188
- configurable: {
1189
- thread_id: session.id,
1190
- checkpoint_ns: checkpointNamespace,
1191
- ...(currentCheckpointId ? { checkpoint_id: currentCheckpointId } : {}),
1192
- },
1193
- },
1194
- )
1195
1176
 
1196
1177
  for await (const event of eventStream) {
1197
1178
  const kind = event.event
@@ -1434,7 +1415,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1434
1415
  } catch (innerErr: unknown) {
1435
1416
  const errName = innerErr instanceof Error ? innerErr.constructor.name : ''
1436
1417
  const errMsg = idleTimedOut
1437
- ? 'Model stream stalled without emitting text or tool results for 90 seconds.'
1418
+ ? `Model stream stalled without emitting text or tool results for ${Math.trunc(runtime.streamIdleStallMs / 1000)} seconds.`
1438
1419
  : requiredToolKickoffTimedOut
1439
1420
  ? `The turn did not start the required workspace tool step within ${Math.trunc(REQUIRED_TOOL_KICKOFF_TIMEOUT_MS / 1000)} seconds.`
1440
1421
  : errorMessage(innerErr)
@@ -1539,24 +1520,6 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1539
1520
  abortController.signal.removeEventListener('abort', onParentAbort)
1540
1521
  }
1541
1522
 
1542
- if (iterationSucceeded) {
1543
- try {
1544
- const state = await agent.getState({
1545
- configurable: {
1546
- thread_id: session.id,
1547
- checkpoint_ns: checkpointNamespace,
1548
- [LANGGRAPH_CHECKPOINTER_CONFIG_KEY]: checkpointSaver,
1549
- },
1550
- }) as { config?: { configurable?: { checkpoint_id?: unknown } } } | null
1551
- const latestCheckpointId = state?.config?.configurable?.checkpoint_id
1552
- if (typeof latestCheckpointId === 'string' && latestCheckpointId.trim()) {
1553
- currentCheckpointId = latestCheckpointId
1554
- }
1555
- } catch (checkpointErr) {
1556
- console.warn('[stream-agent-chat] Failed to refresh latest checkpoint state:', errorMessage(checkpointErr))
1557
- }
1558
- }
1559
-
1560
1523
  if (reachedExecutionBoundary) break
1561
1524
 
1562
1525
  if (!shouldContinue
@@ -1808,7 +1771,9 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1808
1771
  const promptMessage = new HumanMessage({ content: continuationPrompt })
1809
1772
  langchainMessages.push(promptMessage)
1810
1773
  continuationMessages.push(promptMessage)
1811
- pendingGraphMessages = continuationMessages
1774
+ // Provide full conversation history since the agent has no checkpointer
1775
+ // and each iteration starts with only the messages we explicitly pass.
1776
+ pendingGraphMessages = [...langchainMessages]
1812
1777
  lastSegment = ''
1813
1778
  } else if (shouldContinue === 'transient') {
1814
1779
  // Exponential backoff before retrying transient errors; respect Retry-After if present
@@ -1896,7 +1861,7 @@ async function streamAgentChatCore(opts: StreamAgentChatOpts): Promise<StreamAge
1896
1861
  const totalTokens = totalInputTokens + totalOutputTokens
1897
1862
  if (totalTokens > 0) {
1898
1863
  const cost = estimateCost(session.model, totalInputTokens, totalOutputTokens)
1899
- const pluginDefinitionCosts = buildPluginDefinitionCosts(toolsForTurn, toolToPluginMap)
1864
+ const pluginDefinitionCosts = buildPluginDefinitionCosts(tools, toolToPluginMap)
1900
1865
  const usageRecord: UsageRecord = {
1901
1866
  sessionId: session.id,
1902
1867
  messageIndex: history.length,
@@ -100,7 +100,7 @@ function looksLikeIncompleteDeliverableResponse(text: string): boolean {
100
100
  }
101
101
 
102
102
  const ARTIFACT_PATH_EXT_RE = /\.(?:md|txt|html?|json|csv|ya?ml|xml|pdf|png|jpe?g|webp|gif|svg|zip|ts|tsx|js|jsx|mjs|cjs|py|sql|sh)$/i
103
- const EXPLICIT_ARTIFACT_OUTPUT_RE = /\b(?:save|write|output|export)\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
103
+ 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
104
104
 
105
105
  function hasExplicitFileOutputRequest(text: string): boolean {
106
106
  const normalized = text.toLowerCase()
@@ -1534,7 +1534,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1534
1534
  const threadContextBlock = buildConnectorThreadContextBlock(msg, { isFirstThreadTurn: wasCreated })
1535
1535
  if (threadContextBlock) promptParts.push(threadContextBlock)
1536
1536
  // Add connector context
1537
- promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
1537
+ promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" (ID: ${msg.senderId}) is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
1538
1538
 
1539
1539
  ## Response Style
1540
1540
  Be action-first and autonomous: when the user gives an instruction, execute it instead of asking routine follow-up questions.
@@ -2,7 +2,6 @@ import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import {
4
4
  inferAutomaticMemoryCategory,
5
- isDirectMemoryWriteRequest,
6
5
  isCurrentThreadRecallRequest,
7
6
  normalizeMemoryCategory,
8
7
  shouldAutoCaptureMemory,
@@ -42,17 +41,6 @@ test('isCurrentThreadRecallRequest detects same-thread recall without matching s
42
41
  )
43
42
  })
44
43
 
45
- test('isDirectMemoryWriteRequest detects remember-and-confirm turns without matching recall questions', () => {
46
- assert.equal(
47
- isDirectMemoryWriteRequest('Remember that my favorite programming language is Rust and I prefer functional programming patterns. Then confirm what you just stored.'),
48
- true,
49
- )
50
- assert.equal(
51
- isDirectMemoryWriteRequest('What preferences did I tell you earlier in this conversation?'),
52
- false,
53
- )
54
- })
55
-
56
44
  test('shouldAutoCaptureMemory filters noisy turns', () => {
57
45
  assert.equal(shouldAutoCaptureMemory({ message: 'thanks', response: 'Happy to help with that.' }), false)
58
46
  assert.equal(shouldAutoCaptureMemory({ message: 'Please save this to memory', response: 'Stored memory "note".' }), false)
@@ -63,13 +51,15 @@ test('shouldAutoCaptureMemory filters noisy turns', () => {
63
51
  }), true)
64
52
  })
65
53
 
66
- test('inferAutomaticMemoryCategory picks a stable automatic bucket', () => {
54
+ test('inferAutomaticMemoryCategory falls back to knowledge/facts without content-sniffing', () => {
55
+ // Content-sniffing regex removed — the agent picks categories via guidance.
56
+ // inferAutomaticMemoryCategory (called with category "note") should fall through.
67
57
  assert.equal(
68
58
  inferAutomaticMemoryCategory('The user prefers direct status updates.', 'I will keep future updates terse and direct.'),
69
- 'identity/preferences',
59
+ 'knowledge/facts',
70
60
  )
71
61
  assert.equal(
72
62
  inferAutomaticMemoryCategory('We decided to ship the GitHub import first.', 'Decision locked for the next milestone.'),
73
- 'projects/decisions',
63
+ 'knowledge/facts',
74
64
  )
75
65
  })
@@ -6,7 +6,7 @@ const MEMORY_META_RE = /\b(?:remember|memory|memorize|store this|save this|forge
6
6
  const LOW_SIGNAL_RESPONSE_RE = /^(?:HEARTBEAT_OK|NO_MESSAGE)\b/i
7
7
  const CURRENT_THREAD_RECALL_MARKER_RE = /\b(?:this conversation|this chat|this thread|current conversation|current chat|current thread|same thread|same chat|same conversation|earlier in (?:this )?(?:conversation|chat|thread)|from (?:this|our) (?:conversation|chat|thread)|you just stored|you just said|we just discussed|we just decided)\b/i
8
8
  const CURRENT_THREAD_RECALL_INTENT_RE = /\b(?:what|which|who|when|where|did|remind|recap|summarize|repeat|list|tell me|answer|confirm|recall|mention)\b/i
9
- const DIRECT_MEMORY_WRITE_MARKER_RE = /\b(?:remember|memorize|store|save|write to memory|add to memory|update.*memory|correct.*memory)\b/i
9
+ const DIRECT_MEMORY_WRITE_MARKER_RE = /\b(?:remember|memorize|store (?:this|that|the fact|it)|save (?:this|that|the fact|it) (?:to|in) memory|write to memory|add to memory|update.*memory|correct.*memory)\b/i
10
10
  const DIRECT_MEMORY_WRITE_FOLLOWUP_RE = /\b(?:confirm|recap|repeat|summarize|what you just stored|what you saved|what you updated)\b/i
11
11
 
12
12
  function normalizeWhitespace(value: string): string {
@@ -36,16 +36,6 @@ export function isCurrentThreadRecallRequest(message: string): boolean {
36
36
  return CURRENT_THREAD_RECALL_INTENT_RE.test(trimmed) || /\?\s*$/.test(trimmed)
37
37
  }
38
38
 
39
- export function isDirectMemoryWriteRequest(message: string): boolean {
40
- const trimmed = normalizeWhitespace(message)
41
- if (!trimmed) return false
42
- const directWriteLike = DIRECT_MEMORY_WRITE_MARKER_RE.test(trimmed)
43
- if (!directWriteLike) return false
44
- if (/\?\s*$/.test(trimmed) && !DIRECT_MEMORY_WRITE_FOLLOWUP_RE.test(trimmed)) return false
45
- if (isCurrentThreadRecallRequest(trimmed) && !DIRECT_MEMORY_WRITE_FOLLOWUP_RE.test(trimmed)) return false
46
- return true
47
- }
48
-
49
39
  export function shouldAutoCaptureMemoryTurn(message: string, response: string): boolean {
50
40
  const normalizedMessage = normalizeWhitespace(message)
51
41
  const normalizedResponse = normalizeWhitespace(response)
@@ -67,19 +57,20 @@ export function shouldAutoCaptureMemory(
67
57
  return shouldAutoCaptureMemoryTurn(input.message || '', input.response || '')
68
58
  }
69
59
 
70
- export function normalizeMemoryCategory(
71
- input: string | null | undefined,
72
- title: string | null | undefined,
73
- content: string | null | undefined,
74
- ): string {
60
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
61
+ export function normalizeMemoryCategory(input: string | null | undefined, _title?: string | null, _content?: string | null): string {
75
62
  const explicit = lower(input)
76
- const sample = `${lower(title)}\n${lower(content)}`
77
63
 
78
64
  const mapExplicit = (value: string): string | null => {
79
65
  if (!value || value === 'note' || value === 'notes') return null
80
66
  if (['preference', 'preferences', 'likes', 'dislikes'].includes(value)) return 'identity/preferences'
81
67
  if (['identity', 'profile', 'persona'].includes(value)) return 'identity/profile'
82
68
  if (['relationship', 'relationships', 'people'].includes(value)) return 'identity/relationships'
69
+ if (['contact', 'contacts'].includes(value)) return 'identity/contacts'
70
+ if (['routine', 'routines', 'schedule', 'habit', 'habits'].includes(value)) return 'identity/routines'
71
+ if (['event', 'events', 'life event', 'life events', 'significant', 'milestone'].includes(value)) return 'identity/events'
72
+ if (['goal', 'goals', 'objective', 'objectives', 'target', 'targets'].includes(value)) return 'identity/goals'
73
+ if (['instruction', 'instructions', 'directive', 'directives', 'standing order', 'rule', 'rules'].includes(value)) return 'knowledge/instructions'
83
74
  if (['decision', 'decisions', 'choice'].includes(value)) return 'projects/decisions'
84
75
  if (['learning', 'learnings', 'lesson', 'lessons'].includes(value)) return 'projects/learnings'
85
76
  if (['project', 'projects', 'task', 'tasks'].includes(value)) return 'projects/context'
@@ -94,30 +85,9 @@ export function normalizeMemoryCategory(
94
85
  const explicitMapped = mapExplicit(explicit)
95
86
  if (explicitMapped) return explicitMapped
96
87
 
97
- if (/\b(?:prefer(?:s|ence)?|likes?|dislikes?|favorite|timezone|pronouns|call me)\b/.test(sample)) {
98
- return 'identity/preferences'
99
- }
100
- if (/\b(?:wife|husband|partner|friend|manager|teammate|client|customer|relationship)\b/.test(sample)) {
101
- return 'identity/relationships'
102
- }
103
- if (/\b(?:decided|decision|approved|picked|selected|going with|will use)\b/.test(sample)) {
104
- return 'projects/decisions'
105
- }
106
- if (/\b(?:learned|lesson|fixed|solved|root cause|failure|bug|regression|postmortem)\b/.test(sample)) {
107
- return 'projects/learnings'
108
- }
109
- if (/\b(?:error|incident|stack trace|exception|crash)\b/.test(sample)) {
110
- return 'execution/errors'
111
- }
112
- if (/\b(?:project|repo|repository|ticket|task|milestone|deadline|roadmap)\b/.test(sample)) {
113
- return 'projects/context'
114
- }
115
- if (/\b(?:config|credential|endpoint|workspace|path|env var|environment|docker|sandbox)\b/.test(sample)) {
116
- return 'operations/environment'
117
- }
118
- if (/\b(?:fact|documentation|reference|api|schema)\b/.test(sample)) {
119
- return 'knowledge/facts'
120
- }
88
+ // No content-sniffing regex — the agent picks the category via the guidance
89
+ // in its memory policy block. We just normalize explicit aliases above and
90
+ // fall back to knowledge/facts for uncategorized entries.
121
91
  return explicit && explicit !== 'note' && explicit !== 'notes' ? explicit : 'knowledge/facts'
122
92
  }
123
93
 
@@ -404,3 +404,49 @@ describe('buildAgentHeartbeatPrompt', () => {
404
404
  assert.ok(result.includes('Another active task'))
405
405
  })
406
406
  })
407
+
408
+ // ── lightContext config ─────────────────────────────────────────────────
409
+
410
+ describe('heartbeatConfigForSession lightContext', () => {
411
+ it('defaults to false when not set', () => {
412
+ const cfg = mod.heartbeatConfigForSession(
413
+ { id: 's1' },
414
+ { heartbeatIntervalSec: 60 },
415
+ {},
416
+ )
417
+ assert.equal(cfg.lightContext, false)
418
+ })
419
+
420
+ it('inherits from global settings', () => {
421
+ const cfg = mod.heartbeatConfigForSession(
422
+ { id: 's1' },
423
+ { heartbeatIntervalSec: 60, heartbeatLightContext: true },
424
+ {},
425
+ )
426
+ assert.equal(cfg.lightContext, true)
427
+ })
428
+
429
+ it('agent overrides global', () => {
430
+ const agents: Record<string, Record<string, unknown>> = {
431
+ 'a1': { heartbeatLightContext: true },
432
+ }
433
+ const cfg = mod.heartbeatConfigForSession(
434
+ { id: 's1', agentId: 'a1' },
435
+ { heartbeatIntervalSec: 60, heartbeatLightContext: false },
436
+ agents,
437
+ )
438
+ assert.equal(cfg.lightContext, true)
439
+ })
440
+
441
+ it('agent false overrides global true', () => {
442
+ const agents: Record<string, Record<string, unknown>> = {
443
+ 'a1': { heartbeatLightContext: false },
444
+ }
445
+ const cfg = mod.heartbeatConfigForSession(
446
+ { id: 's1', agentId: 'a1' },
447
+ { heartbeatIntervalSec: 60, heartbeatLightContext: true },
448
+ agents,
449
+ )
450
+ assert.equal(cfg.lightContext, false)
451
+ })
452
+ })