@swarmclawai/swarmclaw 0.8.0 → 0.8.2

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 (49) hide show
  1. package/README.md +8 -7
  2. package/package.json +2 -2
  3. package/src/app/api/notifications/route.ts +11 -12
  4. package/src/app/page.tsx +9 -0
  5. package/src/components/chat/chat-list.tsx +10 -9
  6. package/src/components/home/home-view.tsx +13 -2
  7. package/src/components/layout/app-layout.tsx +1 -0
  8. package/src/components/shared/command-palette.tsx +4 -1
  9. package/src/components/shared/notification-center.tsx +7 -1
  10. package/src/components/shared/search-dialog.tsx +10 -2
  11. package/src/lib/local-observability.test.ts +73 -0
  12. package/src/lib/local-observability.ts +47 -0
  13. package/src/lib/notification-utils.test.ts +72 -0
  14. package/src/lib/notification-utils.ts +68 -0
  15. package/src/lib/providers/openclaw.test.ts +21 -1
  16. package/src/lib/providers/openclaw.ts +22 -0
  17. package/src/lib/runtime-loop.ts +1 -1
  18. package/src/lib/server/agent-thread-session.test.ts +41 -0
  19. package/src/lib/server/agent-thread-session.ts +1 -0
  20. package/src/lib/server/chat-execution-advanced.test.ts +7 -0
  21. package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
  22. package/src/lib/server/chat-execution.ts +22 -5
  23. package/src/lib/server/create-notification.test.ts +94 -0
  24. package/src/lib/server/create-notification.ts +31 -25
  25. package/src/lib/server/daemon-state.test.ts +50 -0
  26. package/src/lib/server/daemon-state.ts +121 -38
  27. package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
  28. package/src/lib/server/eval/agent-regression.test.ts +13 -1
  29. package/src/lib/server/eval/agent-regression.ts +221 -1
  30. package/src/lib/server/memory-policy.test.ts +32 -0
  31. package/src/lib/server/memory-policy.ts +25 -0
  32. package/src/lib/server/plugins-advanced.test.ts +7 -0
  33. package/src/lib/server/runtime-settings.test.ts +2 -2
  34. package/src/lib/server/session-tools/crud.test.ts +136 -0
  35. package/src/lib/server/session-tools/crud.ts +44 -2
  36. package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
  37. package/src/lib/server/session-tools/delegate.ts +30 -0
  38. package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
  39. package/src/lib/server/session-tools/discovery.ts +7 -6
  40. package/src/lib/server/session-tools/memory.ts +156 -6
  41. package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
  42. package/src/lib/server/session-tools/subagent.ts +4 -4
  43. package/src/lib/server/storage.ts +14 -1
  44. package/src/lib/server/stream-agent-chat.test.ts +78 -1
  45. package/src/lib/server/stream-agent-chat.ts +225 -22
  46. package/src/lib/server/tool-aliases.ts +1 -1
  47. package/src/lib/server/tool-capability-policy.ts +1 -1
  48. package/src/stores/use-app-store.ts +26 -1
  49. package/src/types/index.ts +4 -0
@@ -8,7 +8,7 @@ import Database from 'better-sqlite3'
8
8
  import { DATA_DIR, IS_BUILD_BOOTSTRAP, WORKSPACE_DIR } from './data-dir'
9
9
  import { normalizeHeartbeatSettingFields } from '@/lib/heartbeat-defaults'
10
10
  import { normalizeRuntimeSettingFields } from '@/lib/runtime-loop'
11
- import type { ExternalAgentRuntime, GatewayProfile, Message } from '@/types'
11
+ import type { AppNotification, ExternalAgentRuntime, GatewayProfile, Message } from '@/types'
12
12
  export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
13
13
 
14
14
  // --- LRU Cache ---
@@ -1175,6 +1175,19 @@ export function deleteNotification(id: string) {
1175
1175
  deleteCollectionItem('notifications', id)
1176
1176
  }
1177
1177
 
1178
+ export function findNotificationByDedupKey(dedupKey: string): AppNotification | null {
1179
+ const raw = getCollectionRawCache('notifications')
1180
+ for (const json of raw.values()) {
1181
+ try {
1182
+ const notification = JSON.parse(json) as AppNotification
1183
+ if (notification.dedupKey === dedupKey) return notification
1184
+ } catch {
1185
+ // ignore malformed
1186
+ }
1187
+ }
1188
+ return null
1189
+ }
1190
+
1178
1191
  export function hasUnreadNotificationWithKey(dedupKey: string): boolean {
1179
1192
  const raw = getCollectionRawCache('notifications')
1180
1193
  for (const json of raw.values()) {
@@ -7,10 +7,13 @@ import {
7
7
  buildExternalWalletExecutionBlock,
8
8
  buildToolDisciplineLines,
9
9
  getExplicitRequiredToolNames,
10
+ isNarrowDirectMemoryWriteTurn,
10
11
  isWalletSimulationResult,
11
12
  looksLikeOpenEndedDeliverableTask,
12
13
  resolveContinuationAssistantText,
13
14
  resolveFinalStreamResponseText,
15
+ shouldAllowToolForDirectMemoryWrite,
16
+ shouldAllowToolForCurrentThreadRecall,
14
17
  shouldTerminateOnSuccessfulMemoryMutation,
15
18
  shouldForceDeliverableFollowthrough,
16
19
  shouldForceExternalExecutionFollowthrough,
@@ -37,6 +40,8 @@ describe('buildToolDisciplineLines', () => {
37
40
  const lines = buildToolDisciplineLines(['files'])
38
41
 
39
42
  assert.ok(lines.some((line) => line.includes('{"action":"read","filePath":"path/to/file.md"}')))
43
+ assert.ok(lines.some((line) => line.includes('exactly N bullet points')))
44
+ assert.ok(lines.some((line) => line.includes('Lower-priority logistics belong in FYI')))
40
45
  })
41
46
 
42
47
  it('adds schedule reuse and stop guidance when schedule tools are enabled', () => {
@@ -63,6 +68,7 @@ describe('buildToolDisciplineLines', () => {
63
68
  assert.ok(lines.some((line) => line.includes('{"action":"send","to":"user@example.com","subject":"...","body":"..."}')))
64
69
  assert.ok(lines.some((line) => line.includes('do not guess or keep re-submitting blank forms')))
65
70
  assert.ok(lines.some((line) => line.includes('store it with `manage_secrets`') && line.includes('do not echo the raw value')))
71
+ assert.ok(lines.some((line) => line.includes('Use `manage_secrets` only for sensitive credentials or tokens')))
66
72
  })
67
73
 
68
74
  it('adds bounded execution guidance for wallet-connected external-service tasks', () => {
@@ -71,6 +77,7 @@ describe('buildToolDisciplineLines', () => {
71
77
  assert.ok(lines.some((line) => line.includes('inspect the available wallet first with `wallet_tool`')))
72
78
  assert.ok(lines.some((line) => line.includes('use a bounded loop') && line.includes('Do not keep browsing once the blocker is clear')))
73
79
  assert.ok(lines.some((line) => line.includes('do not shop across venues indefinitely')))
80
+ assert.ok(lines.some((line) => line.includes('If a direct tool for the job is already enabled in this session, call that tool immediately')))
74
81
  })
75
82
 
76
83
  it('tells agents to stay local when coding tools are already available', () => {
@@ -126,6 +133,57 @@ describe('buildToolDisciplineLines', () => {
126
133
  assert.ok(!streamAgentChatSource.includes('langchainMessages.push(new AIMessage({ content: fullText }))'))
127
134
  })
128
135
 
136
+ it('adds a dedicated current-thread recall block and removes long-term memory tools for those turns', () => {
137
+ assert.ok(streamAgentChatSource.includes('## Current Thread Recall'))
138
+ assert.ok(streamAgentChatSource.includes('## Immediate Memory Routes'))
139
+ assert.ok(streamAgentChatSource.includes('## Direct Memory Write'))
140
+ assert.ok(streamAgentChatSource.includes('call `memory_store` or `memory_update` immediately before any planning, delegation, task creation, or agent management'))
141
+ 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.'))
142
+ assert.ok(streamAgentChatSource.includes('Do NOT call memory tools, web search, or session-history tools'))
143
+ assert.ok(streamAgentChatSource.includes('const currentThreadRecallRequest = !directMemoryWriteOnlyTurn && isCurrentThreadRecallRequest(message)'))
144
+ assert.ok(streamAgentChatSource.includes('const directMemoryWriteOnlyTurn = isNarrowDirectMemoryWriteTurn(message)'))
145
+ assert.ok(streamAgentChatSource.includes('shouldAllowToolForDirectMemoryWrite(toolName)'))
146
+ assert.ok(streamAgentChatSource.includes('shouldAllowToolForCurrentThreadRecall(toolName)'))
147
+ assert.ok(streamAgentChatSource.includes('Preserve hard structural constraints from the original request'))
148
+ assert.ok(streamAgentChatSource.includes('## Exact Structural Constraints'))
149
+ })
150
+
151
+ it('blocks memory, session-history, web, and context tools during same-thread recall turns', () => {
152
+ assert.equal(shouldAllowToolForCurrentThreadRecall('memory_tool'), false)
153
+ assert.equal(shouldAllowToolForCurrentThreadRecall('memory_search'), false)
154
+ assert.equal(shouldAllowToolForCurrentThreadRecall('memory_get'), false)
155
+ assert.equal(shouldAllowToolForCurrentThreadRecall('memory_store'), false)
156
+ assert.equal(shouldAllowToolForCurrentThreadRecall('memory_update'), false)
157
+ assert.equal(shouldAllowToolForCurrentThreadRecall('search_history_tool'), false)
158
+ assert.equal(shouldAllowToolForCurrentThreadRecall('sessions_tool'), false)
159
+ assert.equal(shouldAllowToolForCurrentThreadRecall('web_search'), false)
160
+ assert.equal(shouldAllowToolForCurrentThreadRecall('context_status'), false)
161
+ assert.equal(shouldAllowToolForCurrentThreadRecall('files'), true)
162
+ })
163
+
164
+ it('only allows direct memory write tools during pure remember/store turns', () => {
165
+ assert.equal(shouldAllowToolForDirectMemoryWrite('memory_store'), true)
166
+ assert.equal(shouldAllowToolForDirectMemoryWrite('memory_update'), true)
167
+ assert.equal(shouldAllowToolForDirectMemoryWrite('memory_tool'), false)
168
+ assert.equal(shouldAllowToolForDirectMemoryWrite('manage_capabilities'), false)
169
+ assert.equal(shouldAllowToolForDirectMemoryWrite('files'), false)
170
+ })
171
+
172
+ it('treats long remember-and-confirm turns as narrow direct memory writes', () => {
173
+ assert.equal(
174
+ isNarrowDirectMemoryWriteTurn('Remember that my favorite programming language is Rust and I prefer functional programming patterns. Then confirm what you just stored.'),
175
+ true,
176
+ )
177
+ assert.equal(
178
+ 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".'),
179
+ true,
180
+ )
181
+ assert.equal(
182
+ isNarrowDirectMemoryWriteTurn('Remember that my favorite programming language is Rust, then write a file summarizing it and send it to me.'),
183
+ false,
184
+ )
185
+ })
186
+
129
187
  it('canonicalizes required tool names when checking completion', () => {
130
188
  // The requiredToolsPending filter must canonicalize tool names so that
131
189
  // alias names (e.g. ask_human) match canonical names from LangGraph events.
@@ -256,7 +314,7 @@ describe('resolveContinuationAssistantText', () => {
256
314
  })
257
315
 
258
316
  it('rolls back partial iteration text before transient retries restart the turn', () => {
259
- assert.ok(streamAgentChatSource.includes('const iterationStartState = {'))
317
+ assert.ok(streamAgentChatSource.includes('const iterationStartState:'))
260
318
  assert.ok(streamAgentChatSource.includes('fullText = iterationStartState.fullText'))
261
319
  assert.ok(streamAgentChatSource.includes('lastSegment = iterationStartState.lastSegment'))
262
320
  assert.ok(streamAgentChatSource.includes('lastSettledSegment = iterationStartState.lastSettledSegment'))
@@ -276,6 +334,25 @@ describe('shouldTerminateOnSuccessfulMemoryMutation', () => {
276
334
  )
277
335
  })
278
336
 
337
+ it('treats successful narrow memory write tools as terminal', () => {
338
+ assert.equal(
339
+ shouldTerminateOnSuccessfulMemoryMutation({
340
+ toolName: 'memory_store',
341
+ toolInput: { title: 'Project Kodiak details', value: 'freeze date April 18, 2026' },
342
+ toolOutput: 'Stored memory "Project Kodiak details" (id: abc123). No further memory lookup is needed unless the user asked you to verify.',
343
+ }),
344
+ true,
345
+ )
346
+ assert.equal(
347
+ shouldTerminateOnSuccessfulMemoryMutation({
348
+ toolName: 'memory_update',
349
+ toolInput: { id: 'abc123', value: 'freeze date April 21, 2026' },
350
+ toolOutput: 'Updated memory "Project Kodiak details" (id: abc123). No further memory lookup is needed unless the user asked you to verify.',
351
+ }),
352
+ true,
353
+ )
354
+ })
355
+
279
356
  it('parses JSON tool input and accepts canonical update results', () => {
280
357
  assert.equal(
281
358
  shouldTerminateOnSuccessfulMemoryMutation({
@@ -26,6 +26,7 @@ import {
26
26
  } from './tool-planning'
27
27
  import { ToolLoopTracker } from './tool-loop-detection'
28
28
  import type { LoopDetectionResult } from './tool-loop-detection'
29
+ import { isCurrentThreadRecallRequest, isDirectMemoryWriteRequest } from './memory-policy'
29
30
 
30
31
  /** Extract a breadcrumb title from notable tool completions (task/schedule/agent creation). */
31
32
  interface StreamAgentChatOpts {
@@ -125,6 +126,16 @@ export function buildToolDisciplineLines(enabledPlugins: string[]): string[] {
125
126
 
126
127
  if (uniqueTools.includes('manage_secrets')) {
127
128
  lines.push('When a workflow reveals a password, app password, API key, recovery token, or other secret, store it with `manage_secrets` and do not echo the raw value in assistant text. Refer to the secret by name, service, or secret id instead.')
129
+ lines.push('Use `manage_secrets` only for sensitive credentials or tokens. Do not use it for normal memory, user preferences, durable facts, or project notes.')
130
+ }
131
+
132
+ if (uniqueTools.includes('manage_capabilities')) {
133
+ lines.push('Use `manage_capabilities` only when a needed tool is actually unavailable. If a direct tool for the job is already enabled in this session, call that tool immediately instead of requesting access or re-running discovery.')
134
+ }
135
+
136
+ if (uniqueTools.includes('files') || uniqueTools.includes('edit_file')) {
137
+ lines.push('When the user specifies exact counts or exact section titles for file content, treat those as hard constraints. If a file must have exactly N bullet points, keep the total bullet count at N and put extra required detail into short prose under titled sections unless the user explicitly asked for more bullets.')
138
+ lines.push('When summarizing or restructuring a source document into named sections, make sure each top-level source section is represented somewhere in the output. Lower-priority logistics belong in FYI rather than being dropped.')
128
139
  }
129
140
 
130
141
  if (uniqueTools.includes('delegate') && (uniqueTools.includes('shell') || uniqueTools.includes('files') || uniqueTools.includes('edit_file'))) {
@@ -271,7 +282,12 @@ export function shouldTerminateOnSuccessfulMemoryMutation(params: {
271
282
  }): boolean {
272
283
  const canonicalToolName = canonicalizePluginId(params.toolName) || params.toolName
273
284
  if (canonicalToolName !== 'memory') return false
274
- const action = resolveToolAction(params.toolInput)
285
+ const exactToolName = String(params.toolName || '').trim().toLowerCase()
286
+ const action = exactToolName === 'memory_store'
287
+ ? 'store'
288
+ : exactToolName === 'memory_update'
289
+ ? 'update'
290
+ : resolveToolAction(params.toolInput)
275
291
  if (action !== 'store' && action !== 'update') return false
276
292
  const output = extractSuggestions(params.toolOutput || '').clean.trim()
277
293
  if (!output || /^error[:\s]/i.test(output)) return false
@@ -386,9 +402,20 @@ export function shouldForceDeliverableFollowthrough(params: {
386
402
  // If the user asked for file output but no file-write tool was used, force continuation
387
403
  const userNormalized = params.userMessage.toLowerCase()
388
404
  if (/\b(save|write|output)\b[^.!?\n]{0,60}\b(to|as)\b[^.!?\n]{0,40}(\/|~\/|\.[a-z]{2,5}\b)/.test(userNormalized)) {
389
- const fileToolNames = ['write_file', 'edit_file', 'files', 'shell', 'execute_command']
390
- const usedFileTools = params.toolEvents.some((e) => e.name && fileToolNames.includes(e.name))
391
- if (!usedFileTools) return true
405
+ // Check if a file-writing tool was actually used (not just file-reading).
406
+ // The `files` tool with action: 'read' or 'list' doesn't count as writing.
407
+ const usedFileWriteTools = params.toolEvents.some((e) => {
408
+ if (!e.name) return false
409
+ if (['write_file', 'edit_file'].includes(e.name)) return true
410
+ if (e.name === 'shell' || e.name === 'execute_command') return true
411
+ if (e.name === 'files') {
412
+ // Only count as a write if the tool input specifies action: "write"
413
+ const input = e.input || ''
414
+ return /"action"\s*:\s*"write"/i.test(input)
415
+ }
416
+ return false
417
+ })
418
+ if (!usedFileWriteTools) return true
392
419
  }
393
420
  if (looksLikeIncompleteDeliverableResponse(trimmed)) return true
394
421
  return trimmed.length < 120 && params.toolEvents.length >= 3
@@ -496,19 +523,51 @@ function buildDeliverableFollowthroughPrompt(params: {
496
523
  fullText: string
497
524
  toolEvents: MessageToolEvent[]
498
525
  }): string {
499
- return [
526
+ const lines = [
500
527
  'You are in the middle of a multi-step deliverable and stopped after only a partial batch of work.',
501
528
  'Continue from the existing workspace and artifacts. Do not restart from scratch and do not ask the user to restate the request.',
502
529
  'Do not stop after one partial batch. Finish every requested deliverable that is still outstanding before concluding.',
503
530
  'If a requested artifact cannot be produced, say exactly which artifact is missing, what blocked it, and what you already completed.',
504
531
  'Use the existing files, screenshots, and generated outputs first. Inspect them if needed, then complete the remaining work.',
532
+ 'Preserve hard structural constraints from the original request: exact counts stay exact, required titled sections stay present, and source coverage gaps should be filled instead of skipped.',
505
533
  'End with a concise grouped completion summary that lists exact file paths, upload URLs, localhost URLs/ports, and screenshots you produced.',
534
+ ]
535
+
536
+ // If the user explicitly asked for file output, remind the model to use file tools
537
+ const userNormalized = params.userMessage.toLowerCase()
538
+ const fileOutputMatch = userNormalized.match(/\b(?:save|write|output|export)\b[^.!?\n]{0,80}\b(?:to|as|at|in)\b[^.!?\n]{0,60}(\/[^\s,'"]+|~\/[^\s,'"]+|\.\/[^\s,'"]+)/i)
539
+ if (fileOutputMatch) {
540
+ const fileToolNames = ['write_file', 'edit_file', 'files', 'shell', 'execute_command']
541
+ const usedFileTools = params.toolEvents.some((e) => e.name && fileToolNames.includes(e.name))
542
+ if (!usedFileTools) {
543
+ lines.push(
544
+ '',
545
+ `CRITICAL: The user asked you to save output to a file path (${fileOutputMatch[1] || 'see objective'}). You have NOT used any file-writing tool yet.`,
546
+ 'You MUST use the `files` or `write_file` tool to write the content to the requested path. Do not just include the content in your text response — actually write the file.',
547
+ )
548
+ }
549
+ }
550
+
551
+ lines.push(
506
552
  '',
507
553
  `Objective:\n${params.userMessage}`,
508
554
  '',
509
555
  `Current partial response:\n${params.fullText || '(none)'}`,
510
556
  '',
511
557
  `Recent tool evidence:\n${renderToolEvidence(params.toolEvents) || '(none)'}`,
558
+ )
559
+ return lines.join('\n')
560
+ }
561
+
562
+ function buildExactStructureBlock(userMessage: string): string {
563
+ const exactBulletMatch = userMessage.match(/\bexactly\s+(\d+)\s+bullet points?\b/i)
564
+ if (!exactBulletMatch) return ''
565
+ const bulletCount = exactBulletMatch[1]
566
+ return [
567
+ '## Exact Structural Constraints',
568
+ `The user required exactly ${bulletCount} bullet points.`,
569
+ 'Treat that as a hard file-wide constraint unless the user explicitly says later sections get their own separate bullets.',
570
+ 'If the file also needs titled sections such as Owners or Risks, use short prose under those headings instead of adding more bullet lines.',
512
571
  ].join('\n')
513
572
  }
514
573
 
@@ -529,6 +588,7 @@ const GOAL_DECOMPOSITION_BLOCK = [
529
588
  'When you receive a broad, open-ended goal:',
530
589
  '1. Break it into 3-7 concrete, sequentially-executable subtasks before taking action.',
531
590
  '2. If manage_tasks is available, use it only for durable tracking: multi-turn work, delegation, explicit backlog requests, or work you expect to resume later. Do not create a task for every micro-step.',
591
+ 'Single-step instructions are not broad goals. For direct actions like storing a memory, answering a recall question, editing one file, or sending one message, execute the relevant tool immediately instead of creating tasks or delegating.',
532
592
  '3. Present the plan as a short checklist or numbered list in plain language. If durable tracking is unnecessary, keep it inline instead of creating tasks.',
533
593
  '4. Execute the first substantive subtask immediately — do not stop after planning.',
534
594
  '5. Update only the durable tasks you actually created; otherwise just continue executing and report progress plainly.',
@@ -541,12 +601,15 @@ function buildAgenticExecutionPolicy(opts: {
541
601
  heartbeatIntervalSec: number
542
602
  platformAssignScope?: 'self' | 'all'
543
603
  userMessage?: string
604
+ history?: Message[]
544
605
  responseStyle?: 'concise' | 'normal' | 'detailed' | null
545
606
  responseMaxChars?: number | null
546
607
  }) {
547
608
  const hasTooling = opts.enabledPlugins.length > 0
548
609
  const pluginLines = buildPluginCapabilityLines(opts.enabledPlugins, { platformAssignScope: opts.platformAssignScope })
549
610
  const toolDisciplineLines = buildToolDisciplineLines(opts.enabledPlugins)
611
+ const hasMemoryTools = opts.enabledPlugins.some((toolId) => (canonicalizePluginId(toolId) || toolId) === 'memory')
612
+ const directMemoryWriteOnlyTurn = Boolean(opts.userMessage && isNarrowDirectMemoryWriteTurn(opts.userMessage))
550
613
 
551
614
  const parts: string[] = []
552
615
 
@@ -556,7 +619,7 @@ function buildAgenticExecutionPolicy(opts: {
556
619
  hasTooling
557
620
  ? 'I take initiative — plan briefly, execute tools, evaluate, iterate until done. Never stop at advice when action is implied.'
558
621
  : 'No tools enabled. Be explicit about what tool access is needed.',
559
- 'IMPORTANT: If information was already mentioned in THIS conversation, answer from context — do NOT call memory_tool or web search to look it up again. Only use memory_tool to recall info from PREVIOUS conversations not in the current thread.',
622
+ 'IMPORTANT: If information was already mentioned in THIS conversation, answer from context — do NOT call memory tools or web search to look it up again. Only use memory tools to recall info from PREVIOUS conversations not in the current thread.',
560
623
  'If a skill applies to the task, follow its recommended approach first. Skill-specific commands are faster and more reliable than generic web search. Minimize tool calls — combine steps where possible.',
561
624
  'If a task explicitly names an enabled tool, use that tool before declaring success. A prose request is not a substitute for `ask_human`, and browser work is not a substitute for `email` delivery.',
562
625
  'When `ask_human` is enabled, collect required human input through the tool instead of asking for it only in plain assistant text.',
@@ -567,6 +630,18 @@ function buildAgenticExecutionPolicy(opts: {
567
630
  : 'Loop: BOUNDED — execute multiple steps but finish within recursion budget.',
568
631
  )
569
632
 
633
+ if (hasMemoryTools) {
634
+ parts.push(
635
+ '## Immediate Memory Routes',
636
+ 'If the user asks you to remember, store, or correct a durable fact, call `memory_store` or `memory_update` immediately before any planning, delegation, task creation, or agent management.',
637
+ '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.',
638
+ 'Do not use `manage_tasks`, `manage_agents`, or `delegate` as a substitute for a direct memory write or recall step.',
639
+ )
640
+ }
641
+ if (hasMemoryTools && directMemoryWriteOnlyTurn) {
642
+ parts.push(buildDirectMemoryWriteBlock())
643
+ }
644
+
570
645
  // Plugin-specific operating guidance (collected dynamically from plugins)
571
646
  const guidanceLines = getPluginManager().collectOperatingGuidance(opts.enabledPlugins)
572
647
  if (guidanceLines.length) parts.push(...guidanceLines)
@@ -597,10 +672,104 @@ function buildAgenticExecutionPolicy(opts: {
597
672
  if (opts.userMessage && looksLikeOpenEndedDeliverableTask(opts.userMessage) && opts.enabledPlugins.some((toolId) => toolId === 'files' || toolId === 'edit_file')) {
598
673
  parts.push(OPEN_ENDED_REVISION_BLOCK)
599
674
  }
675
+ if (opts.userMessage) {
676
+ const exactStructureBlock = buildExactStructureBlock(opts.userMessage)
677
+ if (exactStructureBlock) parts.push(exactStructureBlock)
678
+ }
679
+ if (opts.userMessage && isCurrentThreadRecallRequest(opts.userMessage)) {
680
+ parts.push(buildCurrentThreadRecallBlock(opts.history || []))
681
+ }
600
682
 
601
683
  return parts.filter(Boolean).join('\n')
602
684
  }
603
685
 
686
+ function compactThreadRecallText(text: string, maxChars = 180): string {
687
+ const compact = extractSuggestions(text || '').clean.replace(/\s+/g, ' ').trim()
688
+ if (!compact) return ''
689
+ return compact.length > maxChars ? `${compact.slice(0, maxChars - 3)}...` : compact
690
+ }
691
+
692
+ function buildCurrentThreadRecallBlock(history: Message[]): string {
693
+ const recentUserFacts = history
694
+ .filter((entry) => entry.role === 'user' && typeof entry.text === 'string' && entry.text.trim())
695
+ .slice(-3)
696
+ const relevant = history
697
+ .filter((entry) => (entry.role === 'user' || entry.role === 'assistant') && typeof entry.text === 'string' && entry.text.trim())
698
+ .slice(-6)
699
+ const lines = [
700
+ '## Current Thread Recall',
701
+ 'The user is asking about information from this same conversation.',
702
+ 'Treat the current chat history as the authoritative source for this request.',
703
+ 'Do NOT call memory tools, web search, or session-history tools unless the user explicitly asks you to verify outside the current thread.',
704
+ 'Answer directly from the existing conversation with the exact values already stated.',
705
+ 'Prefer the user\'s own earlier words and facts over assistant summaries, persona defaults, soul/config values, or generic background context.',
706
+ 'If the answer is present in the recent thread context below, do not say the information is missing, unknown, or from a first exchange.',
707
+ ]
708
+ if (recentUserFacts.length > 0) {
709
+ lines.push('Recent user-provided facts to trust first:')
710
+ for (const message of recentUserFacts) {
711
+ const snippet = compactThreadRecallText(message.text || '')
712
+ if (!snippet) continue
713
+ lines.push(`- user: ${snippet}`)
714
+ }
715
+ lines.push('These user messages override tool traces, failed tool attempts, persona defaults, and generic background context.')
716
+ }
717
+ if (relevant.length > 0) {
718
+ lines.push('Recent thread context:')
719
+ for (const message of relevant) {
720
+ const snippet = compactThreadRecallText(message.text || '')
721
+ if (!snippet) continue
722
+ lines.push(`- ${message.role}: ${snippet}`)
723
+ }
724
+ }
725
+ return lines.join('\n')
726
+ }
727
+
728
+ function buildDirectMemoryWriteBlock(): string {
729
+ return [
730
+ '## Direct Memory Write',
731
+ 'This turn is a direct request to remember, store, or correct a durable fact.',
732
+ 'Call `memory_store` or `memory_update` immediately, then confirm the stored value succinctly.',
733
+ '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.',
734
+ 'Do not inspect skills, browse the workspace, request capabilities, manage tasks, manage agents, or delegate before the direct memory write is complete.',
735
+ ].join('\n')
736
+ }
737
+
738
+ 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
739
+ 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
740
+
741
+ export function isNarrowDirectMemoryWriteTurn(message: string): boolean {
742
+ const trimmed = String(message || '').trim()
743
+ if (!trimmed || !isDirectMemoryWriteRequest(trimmed)) return false
744
+ if (looksLikeOpenEndedDeliverableTask(trimmed)) return false
745
+ if (DIRECT_MEMORY_WRITE_EXTRA_ACTION_RE.test(trimmed) && !DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE.test(trimmed)) {
746
+ return false
747
+ }
748
+ return !isBroadGoal(trimmed) || DIRECT_MEMORY_WRITE_CONFIRMATION_ONLY_RE.test(trimmed) || !/[?]$/.test(trimmed)
749
+ }
750
+
751
+ const CURRENT_THREAD_RECALL_BLOCKED_TOOL_IDS = new Set([
752
+ 'memory',
753
+ 'manage_sessions',
754
+ 'web',
755
+ 'context_mgmt',
756
+ ])
757
+
758
+ export function shouldAllowToolForCurrentThreadRecall(toolName: string): boolean {
759
+ const canonicalToolName = canonicalizePluginId(toolName) || toolName.trim().toLowerCase()
760
+ return !CURRENT_THREAD_RECALL_BLOCKED_TOOL_IDS.has(canonicalToolName)
761
+ }
762
+
763
+ const DIRECT_MEMORY_WRITE_ALLOWED_TOOL_IDS = new Set([
764
+ 'memory_store',
765
+ 'memory_update',
766
+ ])
767
+
768
+ export function shouldAllowToolForDirectMemoryWrite(toolName: string): boolean {
769
+ const rawToolName = toolName.trim().toLowerCase()
770
+ return DIRECT_MEMORY_WRITE_ALLOWED_TOOL_IDS.has(rawToolName)
771
+ }
772
+
604
773
  export interface StreamAgentChatResult {
605
774
  /** All text accumulated across every LLM turn (for SSE / web UI history). */
606
775
  fullText: string
@@ -704,6 +873,8 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
704
873
 
705
874
  const stateModifierParts: string[] = []
706
875
  const hasProvidedSystemPrompt = typeof systemPrompt === 'string' && systemPrompt.trim().length > 0
876
+ const directMemoryWriteOnlyTurn = isNarrowDirectMemoryWriteTurn(message)
877
+ const currentThreadRecallRequest = !directMemoryWriteOnlyTurn && isCurrentThreadRecallRequest(message)
707
878
 
708
879
  if (hasProvidedSystemPrompt) {
709
880
  stateModifierParts.push(systemPrompt!.trim())
@@ -897,6 +1068,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
897
1068
  heartbeatIntervalSec,
898
1069
  platformAssignScope: agentPlatformAssignScope,
899
1070
  userMessage: message,
1071
+ history,
900
1072
  responseStyle: agentResponseStyle,
901
1073
  responseMaxChars: agentResponseMaxChars,
902
1074
  }),
@@ -916,7 +1088,22 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
916
1088
  projectDescription: activeProjectContext.project?.description || null,
917
1089
  memoryScopeMode: agentMemoryScopeMode,
918
1090
  })
919
- const agent = createReactAgent({ llm, tools, stateModifier })
1091
+ const toolsForTurn = currentThreadRecallRequest
1092
+ ? tools.filter((tool) => {
1093
+ const toolName = typeof (tool as { name?: unknown }).name === 'string'
1094
+ ? String((tool as { name?: unknown }).name)
1095
+ : ''
1096
+ return shouldAllowToolForCurrentThreadRecall(toolName)
1097
+ })
1098
+ : directMemoryWriteOnlyTurn
1099
+ ? tools.filter((tool) => {
1100
+ const toolName = typeof (tool as { name?: unknown }).name === 'string'
1101
+ ? String((tool as { name?: unknown }).name)
1102
+ : ''
1103
+ return shouldAllowToolForDirectMemoryWrite(toolName)
1104
+ })
1105
+ : tools
1106
+ const agent = createReactAgent({ llm, tools: toolsForTurn, stateModifier })
920
1107
  const recursionLimit = getAgentLoopRecursionLimit(runtime)
921
1108
 
922
1109
  // Build message history for context
@@ -1112,7 +1299,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1112
1299
  const MAX_REQUIRED_TOOL_CONTINUES = 2
1113
1300
  const MAX_EXECUTION_FOLLOWTHROUGHS = 1
1114
1301
  const MAX_DELIVERABLE_FOLLOWTHROUGHS = 2
1115
- const MAX_TOOL_SUMMARY_RETRIES = 1
1302
+ const MAX_TOOL_SUMMARY_RETRIES = 2
1116
1303
  let autoContinueCount = 0
1117
1304
  let transientRetryCount = 0
1118
1305
  let requiredToolContinueCount = 0
@@ -1496,10 +1683,18 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1496
1683
 
1497
1684
  if (reachedExecutionBoundary) break
1498
1685
 
1499
- // Tool loop detection: critical severity stops the entire agent turn
1686
+ // Tool loop detection: critical severity stops further tool calls.
1687
+ // However, if tools already produced results but the model has no/trivial text,
1688
+ // we attempt a tool_summary continuation instead of just erroring out.
1500
1689
  if (loopDetectionTriggered) {
1501
- write(`data: ${JSON.stringify({ t: 'err', text: loopDetectionTriggered.message })}\n\n`)
1502
- break
1690
+ const loopTextIsTrivial = !fullText.trim() || (fullText.trim().length < 150 && streamedToolEvents.length >= 2)
1691
+ if (loopTextIsTrivial && streamedToolEvents.length > 0 && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES) {
1692
+ // Override: let the tool_summary check below handle it instead of breaking
1693
+ loopDetectionTriggered = null
1694
+ } else {
1695
+ write(`data: ${JSON.stringify({ t: 'err', text: loopDetectionTriggered.message })}\n\n`)
1696
+ break
1697
+ }
1503
1698
  }
1504
1699
 
1505
1700
  if (
@@ -1590,25 +1785,28 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1590
1785
  })}\n\n`)
1591
1786
  }
1592
1787
 
1593
- // Generic fallback: tools were called but the model produced no text response.
1594
- // This catches edge cases (e.g. after transient retry) where specialized
1595
- // followthrough conditions don't match. Ask the LLM to summarize tool results.
1788
+ // Generic fallback: tools were called but the model produced no substantive text.
1789
+ // Triggers when: (a) text is empty, or (b) text is trivially short (< 150 chars)
1790
+ // and multiple tools ran the agent likely emitted a "I'll do X" preamble but
1791
+ // never synthesized the tool outputs into a real response.
1792
+ const textIsTrivial = !fullText.trim() || (fullText.trim().length < 150 && streamedToolEvents.length >= 2)
1596
1793
  if (
1597
1794
  !shouldContinue
1598
1795
  && hasToolCalls
1599
- && !fullText.trim()
1796
+ && textIsTrivial
1600
1797
  && streamedToolEvents.length > 0
1601
1798
  && toolSummaryRetryCount < MAX_TOOL_SUMMARY_RETRIES
1602
1799
  ) {
1603
1800
  shouldContinue = 'tool_summary'
1604
1801
  toolSummaryRetryCount++
1605
- logExecution(session.id, 'decision', `Tools called but no text generated — forcing summary continuation`, {
1802
+ logExecution(session.id, 'decision', `Tools called but response text is trivial (${fullText.trim().length} chars) — forcing summary continuation`, {
1606
1803
  agentId: session.agentId,
1607
- detail: { toolEventCount: streamedToolEvents.length, toolSummaryRetryCount },
1804
+ detail: { toolEventCount: streamedToolEvents.length, toolSummaryRetryCount, textLength: fullText.trim().length },
1608
1805
  })
1806
+ const summaryReason = !fullText.trim() ? 'empty_response_after_tools' : 'trivial_preamble_after_tools'
1609
1807
  write(`data: ${JSON.stringify({
1610
1808
  t: 'status',
1611
- text: JSON.stringify({ toolSummary: toolSummaryRetryCount, reason: 'empty_response_after_tools' }),
1809
+ text: JSON.stringify({ toolSummary: toolSummaryRetryCount, reason: summaryReason }),
1612
1810
  })}\n\n`)
1613
1811
  }
1614
1812
 
@@ -1669,7 +1867,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1669
1867
  }))
1670
1868
  lastSegment = ''
1671
1869
  } else if (shouldContinue === 'tool_summary') {
1672
- // Model called tools but produced no text — prompt it to summarize the results.
1870
+ // Model called tools but produced no/trivial text — prompt it to synthesize results.
1673
1871
  if (continuationAssistantText) {
1674
1872
  langchainMessages.push(new AIMessage({ content: continuationAssistantText }))
1675
1873
  }
@@ -1677,13 +1875,18 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1677
1875
  .filter((e) => e.output)
1678
1876
  .map((e) => `[${e.name}]: ${(e.output || '').slice(0, 500)}`)
1679
1877
  .slice(0, 6)
1878
+ const preambleNote = fullText.trim()
1879
+ ? `You started with "${fullText.trim().slice(0, 100)}..." but did not follow through with actual results.`
1880
+ : 'Your tool calls completed but you did not provide a response.'
1680
1881
  langchainMessages.push(new HumanMessage({
1681
1882
  content: [
1682
- 'Your tool calls completed but you did not provide a response.',
1883
+ preambleNote,
1683
1884
  'Here are the tool results:',
1684
1885
  ...toolSummaryLines,
1685
1886
  '',
1686
- 'Now answer the original question using these results. Be concise and direct.',
1887
+ `Original request: ${message.slice(0, 500)}`,
1888
+ '',
1889
+ 'Now answer the original request using these tool results. Be concise and direct. Present the findings clearly.',
1687
1890
  ].join('\n'),
1688
1891
  }))
1689
1892
  lastSegment = ''
@@ -1769,7 +1972,7 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
1769
1972
  const totalTokens = totalInputTokens + totalOutputTokens
1770
1973
  if (totalTokens > 0) {
1771
1974
  const cost = estimateCost(session.model, totalInputTokens, totalOutputTokens)
1772
- const pluginDefinitionCosts = buildPluginDefinitionCosts(tools, toolToPluginMap)
1975
+ const pluginDefinitionCosts = buildPluginDefinitionCosts(toolsForTurn, toolToPluginMap)
1773
1976
  const usageRecord: UsageRecord = {
1774
1977
  sessionId: session.id,
1775
1978
  messageIndex: history.length,
@@ -20,7 +20,7 @@ const PLUGIN_ALIAS_GROUPS: string[][] = [
20
20
  ['manage_sessions', 'session_info', 'sessions_tool', 'whoami_tool', 'search_history_tool'],
21
21
  ['schedule_wake', 'schedule'],
22
22
  ['http_request', 'http'],
23
- ['memory', 'memory_tool'],
23
+ ['memory', 'memory_tool', 'memory_search', 'memory_get', 'memory_store', 'memory_update'],
24
24
  ['sandbox', 'sandbox_exec', 'sandbox_list_runtimes'],
25
25
  ['wallet', 'wallet_tool'],
26
26
  ['monitor', 'monitor_tool'],
@@ -55,7 +55,7 @@ const TOOL_DESCRIPTORS: Record<string, ToolDescriptor> = {
55
55
  codex_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_codex_cli'] },
56
56
  opencode_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_opencode_cli'] },
57
57
  gemini_cli: { categories: ['delegation', 'execution'], concreteTools: ['delegate_to_gemini_cli'] },
58
- memory: { categories: ['memory'], concreteTools: ['memory', 'memory_tool', 'context_status', 'context_summarize'] },
58
+ memory: { categories: ['memory'], concreteTools: ['memory', 'memory_tool', 'memory_search', 'memory_get', 'memory_store', 'memory_update', 'context_status', 'context_summarize'] },
59
59
  sandbox: { categories: ['execution', 'filesystem'], concreteTools: ['sandbox', 'sandbox_exec', 'sandbox_list_runtimes'] },
60
60
  git: { categories: ['execution', 'filesystem'], concreteTools: ['git'] },
61
61
  http_request: { categories: ['network'], concreteTools: ['http_request'] },
@@ -6,6 +6,7 @@ import { fetchChats, fetchDirs, fetchProviders, fetchCredentials } from '../lib/
6
6
  import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
8
8
  import { fetchTasks } from '../lib/tasks'
9
+ import { findLatestObservablePlatformSession, isLocalhostBrowser } from '../lib/local-observability'
9
10
  import { api } from '../lib/api-client'
10
11
  import { safeStorageGet, safeStorageGetJson, safeStorageRemove, safeStorageSet } from '../lib/safe-storage'
11
12
 
@@ -247,7 +248,11 @@ export const useAppStore = create<AppState>((set, get) => ({
247
248
  loadSessions: async () => {
248
249
  try {
249
250
  const sessions = await fetchChats()
250
- set({ sessions })
251
+ const currentSessionId = get().currentSessionId
252
+ set({
253
+ sessions,
254
+ currentSessionId: currentSessionId && sessions[currentSessionId] ? currentSessionId : null,
255
+ })
251
256
  } catch {
252
257
  // ignore
253
258
  }
@@ -348,6 +353,26 @@ export const useAppStore = create<AppState>((set, get) => ({
348
353
  }
349
354
  set({ currentAgentId: id })
350
355
  safeStorageSet('sc_agent', id)
356
+ if (isLocalhostBrowser()) {
357
+ let livePlatformSession = findLatestObservablePlatformSession(get().sessions, id)
358
+ if (!livePlatformSession) {
359
+ try {
360
+ const refreshedSessions = await fetchChats()
361
+ const currentSessionId = get().currentSessionId
362
+ set({
363
+ sessions: refreshedSessions,
364
+ currentSessionId: currentSessionId && refreshedSessions[currentSessionId] ? currentSessionId : null,
365
+ })
366
+ livePlatformSession = findLatestObservablePlatformSession(refreshedSessions, id)
367
+ } catch {
368
+ // ignore and fall back to the normal thread path below
369
+ }
370
+ }
371
+ if (livePlatformSession?.id) {
372
+ set({ currentSessionId: livePlatformSession.id })
373
+ return
374
+ }
375
+ }
351
376
  try {
352
377
  const user = get().currentUser || 'default'
353
378
  const session = await api<Session>('POST', `/agents/${id}/thread`, { user })