@swarmclawai/swarmclaw 1.3.6 → 1.4.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 (126) hide show
  1. package/README.md +16 -52
  2. package/next.config.ts +9 -4
  3. package/package.json +18 -10
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +74 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  8. package/src/app/api/a2a/route.ts +56 -0
  9. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  10. package/src/app/api/approvals/route.test.ts +29 -3
  11. package/src/app/api/approvals/route.ts +13 -7
  12. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  13. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  14. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  15. package/src/app/api/chats/chat-route.test.ts +68 -0
  16. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  18. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  19. package/src/app/api/logs/route.test.ts +61 -0
  20. package/src/app/api/logs/route.ts +35 -0
  21. package/src/app/api/openclaw/sync/route.ts +1 -1
  22. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  23. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  24. package/src/app/api/swarmfeed/route.ts +37 -0
  25. package/src/app/api/tts/route.test.ts +82 -0
  26. package/src/app/api/tts/route.ts +13 -6
  27. package/src/app/api/tts/stream/route.ts +12 -5
  28. package/src/app/error.tsx +32 -0
  29. package/src/app/global-error.tsx +33 -0
  30. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  31. package/src/app/protocols/page.tsx +16 -7
  32. package/src/app/swarmfeed/page.tsx +7 -0
  33. package/src/cli/index.js +22 -0
  34. package/src/cli/spec.js +9 -0
  35. package/src/components/agents/agent-avatar.tsx +2 -5
  36. package/src/components/agents/agent-sheet.tsx +10 -0
  37. package/src/components/auth/access-key-gate.tsx +25 -0
  38. package/src/components/layout/error-boundary.tsx +12 -30
  39. package/src/components/layout/error-fallback.tsx +61 -0
  40. package/src/components/layout/sidebar-rail.tsx +52 -0
  41. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  42. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  43. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  44. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  45. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  46. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  47. package/src/components/protocols/builder/node-palette.tsx +97 -0
  48. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  49. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  50. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  51. package/src/components/protocols/builder/node-types/index.ts +9 -0
  52. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  53. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  54. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  55. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  56. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  57. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  58. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  59. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  60. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  61. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  62. package/src/components/skills/skills-workspace.tsx +1 -9
  63. package/src/features/protocols/builder/hooks/index.ts +2 -0
  64. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  65. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  66. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  67. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  68. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  69. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  70. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  71. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  72. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  73. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  74. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  75. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  76. package/src/features/swarmfeed/compose-post.tsx +139 -0
  77. package/src/features/swarmfeed/feed-page.tsx +136 -0
  78. package/src/features/swarmfeed/post-card.tsx +114 -0
  79. package/src/features/swarmfeed/queries.ts +28 -0
  80. package/src/lib/a2a/agent-card.ts +61 -0
  81. package/src/lib/a2a/auth.ts +54 -0
  82. package/src/lib/a2a/client.ts +133 -0
  83. package/src/lib/a2a/discovery.ts +116 -0
  84. package/src/lib/a2a/handlers.ts +176 -0
  85. package/src/lib/a2a/json-rpc-router.ts +38 -0
  86. package/src/lib/a2a/types.ts +95 -0
  87. package/src/lib/app/navigation.ts +1 -0
  88. package/src/lib/app/report-client-error.ts +52 -0
  89. package/src/lib/app/view-constants.ts +9 -1
  90. package/src/lib/providers/anthropic.ts +119 -107
  91. package/src/lib/providers/ollama.ts +34 -14
  92. package/src/lib/providers/openai.ts +154 -142
  93. package/src/lib/providers/openclaw.ts +3 -3
  94. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  95. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  96. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  98. package/src/lib/server/connectors/swarmdock.ts +1 -1
  99. package/src/lib/server/extensions.ts +11 -0
  100. package/src/lib/server/messages/message-repository.ts +31 -0
  101. package/src/lib/server/openclaw/sync.ts +4 -4
  102. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  103. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  104. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  105. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  106. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  107. package/src/lib/server/protocols/protocol-types.ts +1 -0
  108. package/src/lib/server/provider-health.ts +19 -3
  109. package/src/lib/server/safe-parse-body.test.ts +32 -0
  110. package/src/lib/server/safe-parse-body.ts +20 -3
  111. package/src/lib/server/session-tools/delegate.ts +151 -77
  112. package/src/lib/server/storage-auth.ts +10 -2
  113. package/src/lib/server/storage-normalization.ts +11 -0
  114. package/src/lib/server/storage.ts +113 -4
  115. package/src/lib/server/working-state/service.test.ts +2 -3
  116. package/src/lib/server/working-state/service.ts +37 -6
  117. package/src/lib/swarmfeed-client.ts +157 -0
  118. package/src/lib/validation/schemas.ts +1 -1
  119. package/src/stores/slices/data-slice.ts +3 -0
  120. package/src/stores/use-approval-store.ts +4 -1
  121. package/src/types/agent.ts +31 -1
  122. package/src/types/index.ts +1 -0
  123. package/src/types/protocol.ts +19 -0
  124. package/src/types/session.ts +1 -1
  125. package/src/types/swarmfeed.ts +30 -0
  126. package/tsconfig.json +1 -2
@@ -16,8 +16,10 @@ import { deleteSessionWorkingState, loadSessionWorkingState, syncWorkingStateFro
16
16
  import { syncMainLoopToRunContext } from '@/lib/server/run-context'
17
17
  import { buildExecutionBrief, buildExecutionBriefContextBlock } from '@/lib/server/execution-brief'
18
18
  import { cleanText, cleanMultiline } from '@/lib/server/text-normalization'
19
+ import { getGoalById, resolveEffectiveGoal } from '@/lib/server/goals/goal-service'
19
20
 
20
- const LEGACY_META_LINE_RE = /\[(?:MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META)\]\s*(\{[^\n]*\})?/i
21
+ const LEGACY_META_LINE_RE = /\[(?:MAIN_LOOP_META|MAIN_LOOP_PLAN|MAIN_LOOP_REVIEW|AGENT_HEARTBEAT_META|AUTONOMY_TICK)\]\s*(\{[^\n]*\})?/i
22
+ const AUTONOMY_TICK_RE = /\[AUTONOMY_TICK\]\s*(\{[^\n]*\})/i
21
23
  const HEARTBEAT_META_RE = /\[AGENT_HEARTBEAT_META\]\s*(\{[^\n]*\})/i
22
24
  const MAX_PENDING_EVENTS = 16
23
25
  const MAX_TIMELINE_ITEMS = 40
@@ -26,9 +28,12 @@ const DEFAULT_FOLLOWUP_DELAY_MS = 1500
26
28
  const DEFAULT_MAX_FOLLOWUP_CHAIN = 3
27
29
  const MAX_LIFETIME_ITERATIONS = 200
28
30
 
31
+ type MainLoopObjectiveSource = 'goal' | 'working_state' | 'run_context' | 'legacy_tag'
32
+
29
33
  export interface MainLoopState {
30
34
  goal: string | null
31
35
  goalContract: GoalContract | null
36
+ objectiveSource: MainLoopObjectiveSource | null
32
37
  summary: string | null
33
38
  nextAction: string | null
34
39
  planSteps: string[]
@@ -70,6 +75,8 @@ export interface MainLoopState {
70
75
  lastPlannedAt: number | null
71
76
  lastReviewedAt: number | null
72
77
  lastTickAt: number | null
78
+ missionTokens: number
79
+ missionCostUsd: number
73
80
  updatedAt: number
74
81
  }
75
82
 
@@ -99,6 +106,28 @@ export interface HandleMainLoopRunResultInput {
99
106
  estimatedCost?: number
100
107
  }
101
108
 
109
+ interface DurableObjectiveSnapshot {
110
+ goal: string | null
111
+ goalContract: GoalContract | null
112
+ objectiveSource: Exclude<MainLoopObjectiveSource, 'legacy_tag'> | null
113
+ summary: string | null
114
+ nextAction: string | null
115
+ status: MainLoopState['status'] | null
116
+ }
117
+
118
+ interface ParsedAutonomyTick {
119
+ goal?: string
120
+ status?: MainLoopState['status']
121
+ summary?: string
122
+ nextAction?: string
123
+ planSteps?: string[]
124
+ currentStep?: string
125
+ completedSteps?: string[]
126
+ reviewNote?: string
127
+ reviewConfidence?: number
128
+ needsReplan?: boolean
129
+ }
130
+
102
131
  type MainSessionLike = Partial<Session> & Record<string, unknown>
103
132
 
104
133
  const stateMap = hmrSingleton('__swarmclaw_main_loop_state__', () => new Map<string, MainLoopState>())
@@ -126,6 +155,7 @@ function defaultState(): MainLoopState {
126
155
  return {
127
156
  goal: null,
128
157
  goalContract: null,
158
+ objectiveSource: null,
129
159
  summary: null,
130
160
  nextAction: null,
131
161
  planSteps: [],
@@ -148,6 +178,8 @@ function defaultState(): MainLoopState {
148
178
  lastPlannedAt: null,
149
179
  lastReviewedAt: null,
150
180
  lastTickAt: null,
181
+ missionTokens: 0,
182
+ missionCostUsd: 0,
151
183
  updatedAt: now(),
152
184
  }
153
185
  }
@@ -162,6 +194,11 @@ function normalizeAutonomyMode(value: unknown, fallback: MainLoopState['autonomy
162
194
  return value === 'autonomous' || value === 'assist' ? value : fallback
163
195
  }
164
196
 
197
+ function cleanNullableText(value: unknown, max: number): string | null {
198
+ const cleaned = cleanText(value, max)
199
+ return cleaned || null
200
+ }
201
+
165
202
  function uniqueStrings(values: string[], maxItems: number): string[] {
166
203
  const seen = new Set<string>()
167
204
  const out: string[] = []
@@ -282,21 +319,78 @@ function parseHeartbeatMeta(text: string): { goal?: string; status?: MainLoopSta
282
319
  }
283
320
  }
284
321
 
322
+ function parseAutonomyTick(text: string): ParsedAutonomyTick | null {
323
+ const match = (text || '').match(AUTONOMY_TICK_RE)
324
+ if (!match) return null
325
+ try {
326
+ const parsed = JSON.parse(match[1]) as Record<string, unknown>
327
+ const review = parsed.review && typeof parsed.review === 'object' && !Array.isArray(parsed.review)
328
+ ? parsed.review as Record<string, unknown>
329
+ : null
330
+ const payload: ParsedAutonomyTick = {}
331
+ const goal = cleanText(parsed.goal, 400)
332
+ const summary = cleanText(parsed.summary, 500)
333
+ const nextAction = cleanText(parsed.next_action ?? parsed.nextAction, 240)
334
+ const currentStep = cleanText(parsed.current_step ?? parsed.currentStep, 240)
335
+ const planSteps = Array.isArray(parsed.plan_steps ?? parsed.planSteps)
336
+ ? uniqueStrings(((parsed.plan_steps ?? parsed.planSteps) as unknown[]).filter((value): value is string => typeof value === 'string'), 8)
337
+ : []
338
+ const completedSteps = Array.isArray(parsed.completed_steps ?? parsed.completedSteps)
339
+ ? uniqueStrings(((parsed.completed_steps ?? parsed.completedSteps) as unknown[]).filter((value): value is string => typeof value === 'string'), 16)
340
+ : []
341
+ const reviewNote = cleanText(review?.note ?? parsed.review_note ?? parsed.reviewNote, 320)
342
+ const reviewConfidence = normalizeConfidence(review?.confidence ?? parsed.review_confidence ?? parsed.reviewConfidence)
343
+ const needsReplan = review?.needs_replan === true
344
+ || parsed.needs_replan === true
345
+ || parsed.needsReplan === true
346
+ ? true
347
+ : review?.needs_replan === false
348
+ || parsed.needs_replan === false
349
+ || parsed.needsReplan === false
350
+ ? false
351
+ : undefined
352
+
353
+ if (goal) payload.goal = goal
354
+ if (summary) payload.summary = summary
355
+ if (nextAction) payload.nextAction = nextAction
356
+ if (currentStep) payload.currentStep = currentStep
357
+ if (planSteps.length > 0) payload.planSteps = planSteps
358
+ if (completedSteps.length > 0) payload.completedSteps = completedSteps
359
+ if (reviewNote) payload.reviewNote = reviewNote
360
+ if (typeof reviewConfidence === 'number') payload.reviewConfidence = reviewConfidence
361
+ if (typeof needsReplan === 'boolean') payload.needsReplan = needsReplan
362
+ if (parsed.status === 'idle' || parsed.status === 'progress' || parsed.status === 'blocked' || parsed.status === 'ok') {
363
+ payload.status = normalizeStatus(parsed.status, 'idle')
364
+ }
365
+ return Object.keys(payload).length > 0 ? payload : null
366
+ } catch {
367
+ return null
368
+ }
369
+ }
370
+
285
371
  function clampState(state: MainLoopState): MainLoopState {
286
372
  state.planSteps = uniqueStrings(state.planSteps || [], 8)
287
373
  state.workingMemoryNotes = uniqueStrings(state.workingMemoryNotes || [], MAX_WORKING_MEMORY_NOTES)
288
374
  state.pendingEvents = normalizePendingEvents(state.pendingEvents).slice(-MAX_PENDING_EVENTS)
289
375
  state.timeline = normalizeTimeline(state.timeline).slice(-MAX_TIMELINE_ITEMS)
290
- state.goal = cleanText(state.goal, 500)
291
- state.summary = cleanText(state.summary, 1000)
292
- state.nextAction = cleanText(state.nextAction, 240)
293
- state.currentPlanStep = cleanText(state.currentPlanStep, 240)
294
- state.reviewNote = cleanText(state.reviewNote, 320)
376
+ state.goal = cleanNullableText(state.goal, 500)
377
+ state.summary = cleanNullableText(state.summary, 1000)
378
+ state.nextAction = cleanNullableText(state.nextAction, 240)
379
+ state.currentPlanStep = cleanNullableText(state.currentPlanStep, 240)
380
+ state.reviewNote = cleanNullableText(state.reviewNote, 320)
295
381
  state.reviewConfidence = normalizeConfidence(state.reviewConfidence)
382
+ state.objectiveSource = state.objectiveSource === 'goal'
383
+ || state.objectiveSource === 'working_state'
384
+ || state.objectiveSource === 'run_context'
385
+ || state.objectiveSource === 'legacy_tag'
386
+ ? state.objectiveSource
387
+ : null
296
388
  state.momentumScore = Math.max(-10, Math.min(10, Math.trunc(state.momentumScore || 0)))
297
389
  state.followupChainCount = Math.max(0, Math.min(10, Math.trunc(state.followupChainCount || 0)))
298
390
  state.lifetimeIterations = Math.max(0, Math.trunc(state.lifetimeIterations || 0))
299
391
  state.metaMissCount = Math.max(0, Math.min(100, Math.trunc(state.metaMissCount || 0)))
392
+ state.missionTokens = Math.max(0, Math.trunc(state.missionTokens || 0))
393
+ state.missionCostUsd = Number.isFinite(state.missionCostUsd) ? Math.max(0, state.missionCostUsd || 0) : 0
300
394
  state.skillBlocker = normalizeSkillBlocker(state.skillBlocker)
301
395
  state.updatedAt = typeof state.updatedAt === 'number' && Number.isFinite(state.updatedAt) ? Math.trunc(state.updatedAt) : now()
302
396
  return state
@@ -307,6 +401,9 @@ function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
307
401
  if (input) {
308
402
  if (input.goalContract) next.goalContract = input.goalContract
309
403
  if (typeof input.goal === 'string' || input.goal === null) next.goal = input.goal
404
+ if (input.objectiveSource === 'goal' || input.objectiveSource === 'working_state' || input.objectiveSource === 'run_context' || input.objectiveSource === 'legacy_tag' || input.objectiveSource === null) {
405
+ next.objectiveSource = input.objectiveSource
406
+ }
310
407
  if (typeof input.summary === 'string' || input.summary === null) next.summary = input.summary
311
408
  if (typeof input.nextAction === 'string' || input.nextAction === null) next.nextAction = input.nextAction
312
409
  if (Array.isArray(input.planSteps)) next.planSteps = [...input.planSteps]
@@ -331,6 +428,8 @@ function normalizeState(input?: Partial<MainLoopState> | null): MainLoopState {
331
428
  if (typeof input.lastPlannedAt === 'number' || input.lastPlannedAt === null) next.lastPlannedAt = input.lastPlannedAt ?? null
332
429
  if (typeof input.lastReviewedAt === 'number' || input.lastReviewedAt === null) next.lastReviewedAt = input.lastReviewedAt ?? null
333
430
  if (typeof input.lastTickAt === 'number' || input.lastTickAt === null) next.lastTickAt = input.lastTickAt ?? null
431
+ if (typeof input.missionTokens === 'number') next.missionTokens = input.missionTokens
432
+ if (typeof input.missionCostUsd === 'number') next.missionCostUsd = input.missionCostUsd
334
433
  if (typeof input.updatedAt === 'number') next.updatedAt = input.updatedAt
335
434
  }
336
435
  return clampState(next)
@@ -373,6 +472,155 @@ function extractLatestGoal(messages: Message[]): { goal: string | null; goalCont
373
472
  return { goal, goalContract }
374
473
  }
375
474
 
475
+ function mapWorkingStateStatusToMainLoopStatus(value: unknown): MainLoopState['status'] | null {
476
+ if (value === 'completed') return 'ok'
477
+ if (value === 'blocked' || value === 'waiting') return 'blocked'
478
+ if (value === 'progress') return 'progress'
479
+ if (value === 'idle') return 'idle'
480
+ return null
481
+ }
482
+
483
+ function buildGoalContractFromGoal(goal: Record<string, unknown> | null): GoalContract | null {
484
+ if (!goal) return null
485
+ const objective = cleanMultiline(goal.objective, 900)
486
+ if (!objective) return null
487
+ const constraints = Array.isArray(goal.constraints)
488
+ ? goal.constraints.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
489
+ : []
490
+ const budgetUsd = typeof goal.budgetUsd === 'number' && Number.isFinite(goal.budgetUsd) ? goal.budgetUsd : null
491
+ const deadlineAt = typeof goal.deadlineAt === 'number' && Number.isFinite(goal.deadlineAt) ? goal.deadlineAt : null
492
+ const successMetric = cleanText(goal.successMetric, 240)
493
+ return {
494
+ objective,
495
+ constraints: constraints.length ? constraints : undefined,
496
+ budgetUsd,
497
+ deadlineAt,
498
+ successMetric,
499
+ }
500
+ }
501
+
502
+ function resolveSessionGoalRecord(session: Session | null | undefined): Record<string, unknown> | null {
503
+ if (!session) return null
504
+ const s = session as unknown as Record<string, unknown>
505
+ const directGoalId = typeof s.goalId === 'string'
506
+ ? String(s.goalId).trim()
507
+ : ''
508
+ if (directGoalId) {
509
+ const directGoal = getGoalById(directGoalId)
510
+ if (directGoal) return directGoal as unknown as Record<string, unknown>
511
+ }
512
+ const legacyMissionId = typeof s.missionId === 'string'
513
+ ? String(s.missionId).trim()
514
+ : ''
515
+ if (legacyMissionId) {
516
+ const missionGoal = getGoalById(legacyMissionId)
517
+ if (missionGoal) return missionGoal as unknown as Record<string, unknown>
518
+ }
519
+ const effectiveGoal = resolveEffectiveGoal({
520
+ agentId: session.agentId || null,
521
+ projectId: session.projectId || null,
522
+ })
523
+ return effectiveGoal ? effectiveGoal as unknown as Record<string, unknown> : null
524
+ }
525
+
526
+ function resolveRunContextNextAction(session: Session | null | undefined): string | null {
527
+ const runContext = session?.runContext
528
+ if (!runContext || !Array.isArray(runContext.currentPlan)) return null
529
+ const completed = new Set(
530
+ Array.isArray(runContext.completedSteps)
531
+ ? runContext.completedSteps
532
+ .map((entry) => cleanText(entry, 240)?.toLowerCase())
533
+ .filter((entry): entry is string => Boolean(entry))
534
+ : [],
535
+ )
536
+ for (const step of runContext.currentPlan) {
537
+ const cleaned = cleanText(step, 240)
538
+ if (!cleaned) continue
539
+ if (completed.has(cleaned.toLowerCase())) continue
540
+ return cleaned
541
+ }
542
+ return null
543
+ }
544
+
545
+ function resolveDurableObjectiveSnapshot(
546
+ sessionId: string,
547
+ session: Session | null | undefined,
548
+ current: MainLoopState,
549
+ ): DurableObjectiveSnapshot {
550
+ const workingState = loadSessionWorkingState(sessionId)
551
+ if (workingState && (workingState.objective || workingState.summary || workingState.nextAction || (workingState.planSteps?.length || 0) > 0)) {
552
+ const workingPlan = Array.isArray(workingState.planSteps) ? workingState.planSteps : []
553
+ const activeStep = workingPlan.find((step) => step.status === 'active')
554
+ const firstPlanStep = workingPlan.find((step) => step.status !== 'resolved' && step.status !== 'superseded')
555
+ return {
556
+ goal: cleanMultiline(workingState.objective, 900) || null,
557
+ goalContract: mergeGoalContracts(
558
+ buildGoalContractFromGoal(resolveSessionGoalRecord(session)),
559
+ workingState.objective ? parseGoalContractFromText(workingState.objective) : null,
560
+ ),
561
+ objectiveSource: 'working_state',
562
+ summary: cleanText(workingState.summary, 1000) || null,
563
+ nextAction: cleanText(workingState.nextAction || activeStep?.text || firstPlanStep?.text, 240) || null,
564
+ status: mapWorkingStateStatusToMainLoopStatus(workingState.status),
565
+ }
566
+ }
567
+
568
+ const goalRecord = resolveSessionGoalRecord(session)
569
+ const goalObjective = cleanMultiline(goalRecord?.objective, 900)
570
+ if (goalObjective) {
571
+ return {
572
+ goal: goalObjective,
573
+ goalContract: buildGoalContractFromGoal(goalRecord),
574
+ objectiveSource: 'goal',
575
+ summary: current.summary,
576
+ nextAction: current.nextAction,
577
+ status: null,
578
+ }
579
+ }
580
+
581
+ const runContext = session?.runContext
582
+ const runContextObjective = cleanMultiline(runContext?.objective, 900)
583
+ if (runContextObjective) {
584
+ return {
585
+ goal: runContextObjective,
586
+ goalContract: parseGoalContractFromText(runContextObjective),
587
+ objectiveSource: 'run_context',
588
+ summary: current.summary,
589
+ nextAction: resolveRunContextNextAction(session),
590
+ status: null,
591
+ }
592
+ }
593
+
594
+ return {
595
+ goal: null,
596
+ goalContract: null,
597
+ objectiveSource: null,
598
+ summary: null,
599
+ nextAction: null,
600
+ status: null,
601
+ }
602
+ }
603
+
604
+ function applyDurableObjectiveOverlay(
605
+ sessionId: string,
606
+ state: MainLoopState,
607
+ session: Session | null | undefined,
608
+ ): MainLoopState {
609
+ const durable = resolveDurableObjectiveSnapshot(sessionId, session, state)
610
+ if (!durable.objectiveSource) {
611
+ if (!state.objectiveSource && state.goal) state.objectiveSource = 'legacy_tag'
612
+ return clampState(state)
613
+ }
614
+
615
+ if (durable.goal) state.goal = durable.goal
616
+ state.objectiveSource = durable.objectiveSource
617
+ state.goalContract = mergeGoalContracts(state.goalContract, durable.goalContract)
618
+ if (durable.summary) state.summary = durable.summary
619
+ if (durable.nextAction) state.nextAction = durable.nextAction
620
+ if (durable.status && state.status !== 'blocked') state.status = durable.status
621
+ return clampState(state)
622
+ }
623
+
376
624
  function hydrateStateFromSession(sessionId: string): MainLoopState | null {
377
625
  const sessions = loadSessions()
378
626
  const session = sessions[sessionId]
@@ -386,11 +634,15 @@ function hydrateStateFromSession(sessionId: string): MainLoopState | null {
386
634
  const initial = extractLatestGoal(messages)
387
635
  hydrated.goal = initial.goal
388
636
  hydrated.goalContract = initial.goalContract
637
+ hydrated.objectiveSource = initial.goal ? 'legacy_tag' : null
389
638
 
390
639
  for (const message of messages) {
391
640
  if (message.role !== 'assistant' || typeof message.text !== 'string') continue
392
641
  const heartbeat = parseHeartbeatMeta(message.text)
393
- if (heartbeat?.goal) hydrated.goal = heartbeat.goal
642
+ if (heartbeat?.goal) {
643
+ hydrated.goal = heartbeat.goal
644
+ hydrated.objectiveSource = 'legacy_tag'
645
+ }
394
646
  if (heartbeat?.summary) hydrated.summary = heartbeat.summary
395
647
  if (heartbeat?.nextAction) hydrated.nextAction = heartbeat.nextAction
396
648
  if (heartbeat?.status) hydrated.status = heartbeat.status
@@ -415,7 +667,7 @@ function hydrateStateFromSession(sessionId: string): MainLoopState | null {
415
667
  }
416
668
  }
417
669
 
418
- return mergeWorkingStateIntoMainLoopState(sessionId, normalizeState(hydrated))
670
+ return applyDurableObjectiveOverlay(sessionId, mergeWorkingStateIntoMainLoopState(sessionId, normalizeState(hydrated)), session)
419
671
  }
420
672
 
421
673
  function persistState(sessionId: string, state: MainLoopState): void {
@@ -423,6 +675,10 @@ function persistState(sessionId: string, state: MainLoopState): void {
423
675
  upsertPersistedMainLoopState(sessionId, normalized as unknown as Record<string, unknown>)
424
676
  const session = getSession(sessionId)
425
677
  if (!session) return
678
+ const shouldSyncDurableWorkingState = normalized.objectiveSource === 'goal'
679
+ || normalized.objectiveSource === 'working_state'
680
+ || normalized.objectiveSource === 'run_context'
681
+ if (!shouldSyncDurableWorkingState) return
426
682
  void syncWorkingStateFromMainLoopState({
427
683
  sessionId,
428
684
  goal: normalized.goal,
@@ -432,10 +688,11 @@ function persistState(sessionId: string, state: MainLoopState): void {
432
688
  : normalized.status === 'blocked'
433
689
  ? 'blocked'
434
690
  : normalized.status === 'progress'
435
- ? 'progress'
691
+ ? 'progress'
436
692
  : 'idle',
437
693
  nextAction: normalized.nextAction,
438
694
  planSteps: normalized.planSteps,
695
+ completedPlanSteps: normalized.completedPlanSteps,
439
696
  blockers: normalized.skillBlocker ? [{
440
697
  summary: normalized.skillBlocker.summary,
441
698
  kind: normalized.skillBlocker.status === 'approval_requested' ? 'approval' : 'other',
@@ -446,7 +703,12 @@ function persistState(sessionId: string, state: MainLoopState): void {
446
703
  function getOrCreateState(sessionId: string): MainLoopState | null {
447
704
  const existing = stateMap.get(sessionId)
448
705
  if (existing) {
449
- const merged = mergeWorkingStateIntoMainLoopState(sessionId, existing)
706
+ const sessions = loadSessions()
707
+ const merged = applyDurableObjectiveOverlay(
708
+ sessionId,
709
+ mergeWorkingStateIntoMainLoopState(sessionId, existing),
710
+ sessions[sessionId] as Session | undefined,
711
+ )
450
712
  stateMap.set(sessionId, merged)
451
713
  return merged
452
714
  }
@@ -454,7 +716,12 @@ function getOrCreateState(sessionId: string): MainLoopState | null {
454
716
  // Try disk (survives full restart)
455
717
  const persisted = loadPersistedMainLoopState(sessionId) as Partial<MainLoopState> | null
456
718
  if (persisted) {
457
- const restored = mergeWorkingStateIntoMainLoopState(sessionId, normalizeState(persisted))
719
+ const sessions = loadSessions()
720
+ const restored = applyDurableObjectiveOverlay(
721
+ sessionId,
722
+ mergeWorkingStateIntoMainLoopState(sessionId, normalizeState(persisted)),
723
+ sessions[sessionId] as Session | undefined,
724
+ )
458
725
  stateMap.set(sessionId, restored)
459
726
  return restored
460
727
  }
@@ -789,6 +1056,14 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
789
1056
  const effectiveGoalContract = latestExternalGoal.goalContract
790
1057
  ? mergeGoalContracts(state.goalContract, latestExternalGoal.goalContract)
791
1058
  : state.goalContract
1059
+ const durableSnapshot = resolveDurableObjectiveSnapshot(
1060
+ String(candidate.id),
1061
+ (persistedSession || candidate as Session),
1062
+ state,
1063
+ )
1064
+ const usesDurableObjective = durableSnapshot.objectiveSource === 'goal'
1065
+ || durableSnapshot.objectiveSource === 'working_state'
1066
+ || durableSnapshot.objectiveSource === 'run_context'
792
1067
 
793
1068
  const heartbeatSession = (persistedSession || candidate as Session)
794
1069
  const executionBrief = buildExecutionBrief({
@@ -819,12 +1094,22 @@ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: s
819
1094
  'Use the execution brief and pending external events shown above as the authoritative state for this tick.',
820
1095
  'Do not infer or repeat old tasks from prior heartbeats.',
821
1096
  'Prefer taking the single highest-value next step over restating the plan. Do not repeat completed work.',
822
- 'If you revise the plan, emit exactly one line like:',
823
- '[MAIN_LOOP_PLAN]{"steps":["step 1","step 2"],"current_step":"step 1","completed_steps":["step 0"]}',
824
- 'After acting, emit exactly one review line like:',
825
- '[MAIN_LOOP_REVIEW]{"note":"what changed","confidence":0.72,"needs_replan":false}',
826
- 'If you are actively progressing or you changed the plan, also emit [AGENT_HEARTBEAT_META] with goal/status/next_action.',
827
- 'Reply HEARTBEAT_OK only when nothing needs action right now.',
1097
+ ...(usesDurableObjective
1098
+ ? [
1099
+ 'If anything materially changed, emit exactly one line like:',
1100
+ '[AUTONOMY_TICK]{"status":"progress","summary":"what changed","next_action":"best next step","plan_steps":["step 1","step 2"],"current_step":"step 1","completed_steps":["done"],"review":{"note":"brief review","confidence":0.72,"needs_replan":false}}',
1101
+ 'The durable objective, current execution brief, and working state are authoritative for this thread.',
1102
+ 'If nothing materially changed, stop cleanly instead of replaying legacy heartbeat metadata.',
1103
+ 'Reply HEARTBEAT_OK only when nothing needs action right now.',
1104
+ ]
1105
+ : [
1106
+ 'If you revise the plan, emit exactly one line like:',
1107
+ '[MAIN_LOOP_PLAN]{"steps":["step 1","step 2"],"current_step":"step 1","completed_steps":["step 0"]}',
1108
+ 'After acting, emit exactly one review line like:',
1109
+ '[MAIN_LOOP_REVIEW]{"note":"what changed","confidence":0.72,"needs_replan":false}',
1110
+ 'If you are actively progressing or you changed the plan, also emit [AGENT_HEARTBEAT_META] with goal/status/next_action.',
1111
+ 'Reply HEARTBEAT_OK only when nothing needs action right now.',
1112
+ ]),
828
1113
  ].filter(Boolean).join('\n')
829
1114
  }
830
1115
 
@@ -881,6 +1166,7 @@ export function pruneMainLoopState(liveSessionIds: Set<string>): number {
881
1166
  export function setMainLoopStateForSession(sessionId: string, patch: Partial<MainLoopState>): MainLoopState | null {
882
1167
  const current = getOrCreateState(sessionId)
883
1168
  if (!current) return null
1169
+ const sessions = loadSessions()
884
1170
  const next = normalizeState({
885
1171
  ...current,
886
1172
  ...patch,
@@ -890,9 +1176,10 @@ export function setMainLoopStateForSession(sessionId: string, patch: Partial<Mai
890
1176
  workingMemoryNotes: patch.workingMemoryNotes ?? current.workingMemoryNotes,
891
1177
  updatedAt: now(),
892
1178
  })
893
- stateMap.set(sessionId, next)
894
- persistState(sessionId, next)
895
- return normalizeState(next)
1179
+ const overlaid = applyDurableObjectiveOverlay(sessionId, next, sessions[sessionId] as Session | undefined)
1180
+ stateMap.set(sessionId, overlaid)
1181
+ persistState(sessionId, overlaid)
1182
+ return normalizeState(overlaid)
896
1183
  }
897
1184
 
898
1185
  export function pushMainLoopEventToMainSessions(input: PushMainLoopEventInput): number {
@@ -937,36 +1224,69 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
937
1224
 
938
1225
  const sessions = loadSessions()
939
1226
  const session = sessions[input.sessionId] as unknown as Session | undefined
1227
+ const durableBefore = resolveDurableObjectiveSnapshot(input.sessionId, session || null, state)
1228
+ const hasDurableObjective = durableBefore.objectiveSource === 'goal'
1229
+ || durableBefore.objectiveSource === 'working_state'
1230
+ || durableBefore.objectiveSource === 'run_context'
940
1231
  const isDirectUserChat = !input.internal && input.source === 'chat'
941
1232
  const resultText = input.resultText || ''
942
1233
  const persistedText = stripMainLoopMetaForPersistence(resultText)
943
1234
  const toolEvents = Array.isArray(input.toolEvents) ? input.toolEvents : []
944
1235
  const toolNames = uniqueStrings(toolEvents.map((event) => event.name || '').filter(Boolean), 8)
1236
+ const autonomyTick = parseAutonomyTick(resultText)
945
1237
  const heartbeat = parseHeartbeatMeta(resultText)
946
1238
  const plan = parseMainLoopPlan(resultText)
947
1239
  const review = parseMainLoopReview(resultText)
1240
+ const shouldAcceptLegacyHeartbeatMeta = !input.internal || !hasDurableObjective
1241
+ const shouldAcceptStructuredAutonomyTick = Boolean(autonomyTick)
948
1242
  const shouldCaptureMessageGoal = !input.internal
949
1243
  const messageGoal = shouldCaptureMessageGoal ? parseGoalContractFromText(input.message || '') : null
950
1244
  const nowTs = now()
951
1245
  state.lifetimeIterations++
952
1246
  if (messageGoal) state.goalContract = mergeGoalContracts(state.goalContract, messageGoal)
953
- if (!state.goal && shouldCaptureMessageGoal) state.goal = cleanMultiline(input.message, 900)
954
- if (heartbeat?.goal) state.goal = heartbeat.goal
955
- if (heartbeat?.summary) state.summary = heartbeat.summary
956
- if (heartbeat?.nextAction) state.nextAction = heartbeat.nextAction
957
- if (heartbeat?.status) state.status = heartbeat.status
958
-
959
- if (plan?.steps?.length) state.planSteps = plan.steps
960
- if (plan?.current_step) state.currentPlanStep = plan.current_step
961
- if (plan?.completed_steps?.length) {
1247
+ if (!state.goal && shouldCaptureMessageGoal) {
1248
+ state.goal = cleanMultiline(input.message, 900)
1249
+ if (state.goal) state.objectiveSource = 'legacy_tag'
1250
+ }
1251
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.goal) state.goal = autonomyTick.goal
1252
+ if (shouldAcceptLegacyHeartbeatMeta && heartbeat?.goal) {
1253
+ state.goal = heartbeat.goal
1254
+ state.objectiveSource = 'legacy_tag'
1255
+ }
1256
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.summary) state.summary = autonomyTick.summary
1257
+ if (shouldAcceptLegacyHeartbeatMeta && heartbeat?.summary) state.summary = heartbeat.summary
1258
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.nextAction) state.nextAction = autonomyTick.nextAction
1259
+ if (shouldAcceptLegacyHeartbeatMeta && heartbeat?.nextAction) state.nextAction = heartbeat.nextAction
1260
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.status) state.status = autonomyTick.status
1261
+ if (shouldAcceptLegacyHeartbeatMeta && heartbeat?.status) state.status = heartbeat.status
1262
+
1263
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.planSteps?.length) state.planSteps = autonomyTick.planSteps
1264
+ if (shouldAcceptLegacyHeartbeatMeta && plan?.steps?.length) state.planSteps = plan.steps
1265
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.currentStep) state.currentPlanStep = autonomyTick.currentStep
1266
+ if (shouldAcceptLegacyHeartbeatMeta && plan?.current_step) state.currentPlanStep = plan.current_step
1267
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.completedSteps?.length) {
1268
+ const merged = new Set([...state.completedPlanSteps, ...autonomyTick.completedSteps])
1269
+ state.completedPlanSteps = [...merged].slice(0, 16)
1270
+ }
1271
+ if (shouldAcceptLegacyHeartbeatMeta && plan?.completed_steps?.length) {
962
1272
  const merged = new Set([...state.completedPlanSteps, ...plan.completed_steps])
963
1273
  state.completedPlanSteps = [...merged].slice(0, 16)
964
1274
  }
965
- if (plan) state.lastPlannedAt = nowTs
966
-
967
- if (review?.note) state.reviewNote = review.note
968
- if (typeof review?.confidence === 'number') state.reviewConfidence = review.confidence
969
- if (review) state.lastReviewedAt = nowTs
1275
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick) state.lastPlannedAt = nowTs
1276
+ if (shouldAcceptLegacyHeartbeatMeta && plan) state.lastPlannedAt = nowTs
1277
+
1278
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick?.reviewNote) state.reviewNote = autonomyTick.reviewNote
1279
+ if (shouldAcceptLegacyHeartbeatMeta && review?.note) state.reviewNote = review.note
1280
+ if (shouldAcceptStructuredAutonomyTick && typeof autonomyTick?.reviewConfidence === 'number') state.reviewConfidence = autonomyTick.reviewConfidence
1281
+ if (shouldAcceptLegacyHeartbeatMeta && typeof review?.confidence === 'number') state.reviewConfidence = review.confidence
1282
+ if (shouldAcceptStructuredAutonomyTick && autonomyTick) state.lastReviewedAt = nowTs
1283
+ if (shouldAcceptLegacyHeartbeatMeta && review) state.lastReviewedAt = nowTs
1284
+
1285
+ const turnTokens = (input.inputTokens || 0) + (input.outputTokens || 0)
1286
+ if (turnTokens > 0) state.missionTokens += turnTokens
1287
+ if (typeof input.estimatedCost === 'number' && Number.isFinite(input.estimatedCost) && input.estimatedCost > 0) {
1288
+ state.missionCostUsd += input.estimatedCost
1289
+ }
970
1290
 
971
1291
  if (toolNames.length > 0) {
972
1292
  appendWorkingMemory(state, `Used tools: ${toolNames.join(', ')}`)
@@ -992,10 +1312,16 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
992
1312
  const cleanedResult = persistedText.trim()
993
1313
  const waitingForExternal = extractWaitSignal(resultText)
994
1314
  const gotTerminalAck = /^HEARTBEAT_OK$/i.test(cleanedResult) || /^NO_MESSAGE$/i.test(cleanedResult)
1315
+ const ignoredLegacyCompatibilityPulse = input.internal
1316
+ && hasDurableObjective
1317
+ && !input.error
1318
+ && !toolEvents.length
1319
+ && !autonomyTick
1320
+ && Boolean(heartbeat || plan || review)
995
1321
  const successfulChatDelivery = isDirectUserChat && !input.error && hasSuccessfulChatDelivery(toolEvents)
996
1322
  const selectedSkillNote = summarizeUseSkillToolEvent(toolEvents)
997
1323
  if (selectedSkillNote) appendWorkingMemory(state, selectedSkillNote)
998
- state.metaMissCount = heartbeat || plan || review || gotTerminalAck ? 0 : state.metaMissCount + 1
1324
+ state.metaMissCount = autonomyTick || heartbeat || plan || review || gotTerminalAck ? 0 : state.metaMissCount + 1
999
1325
  const skillQuery = cleanText(state.nextAction || input.message || state.goal, 240)
1000
1326
  let skillBlocker = deriveSkillBlockerFromToolEvents({
1001
1327
  toolEvents,
@@ -1011,6 +1337,9 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1011
1337
  skillBlocker = null
1012
1338
  }
1013
1339
  state.skillBlocker = skillBlocker
1340
+ if (!autonomyTick) {
1341
+ applyDurableObjectiveOverlay(input.sessionId, state, session || null)
1342
+ }
1014
1343
 
1015
1344
  if (input.internal) {
1016
1345
  state.pendingEvents = []
@@ -1046,7 +1375,9 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1046
1375
  state.paused = false
1047
1376
  }
1048
1377
 
1049
- const needsReplan = review?.needs_replan === true || ((review?.confidence ?? 1) < 0.45)
1378
+ const needsReplan = autonomyTick?.needsReplan === true
1379
+ || review?.needs_replan === true
1380
+ || ((autonomyTick?.reviewConfidence ?? review?.confidence ?? 1) < 0.45)
1050
1381
  const limit = followupLimit(input.sessionId)
1051
1382
 
1052
1383
  let followup: MainLoopFollowupRequest | null = null
@@ -1063,7 +1394,7 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1063
1394
  }
1064
1395
  } else if (!input.internal) {
1065
1396
  state.followupChainCount = 0
1066
- } else if (input.error || waitingForExternal || gotTerminalAck) {
1397
+ } else if (input.error || waitingForExternal || gotTerminalAck || ignoredLegacyCompatibilityPulse) {
1067
1398
  state.followupChainCount = 0
1068
1399
  if (gotTerminalAck && state.status !== 'blocked') state.status = 'ok'
1069
1400
  } else {
@@ -1101,10 +1432,15 @@ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): Ma
1101
1432
  stateMap.set(input.sessionId, finalClamped)
1102
1433
  persistState(input.sessionId, finalClamped)
1103
1434
 
1104
- // Project orchestrator state into session RunContext (non-critical)
1105
- try {
1106
- syncMainLoopToRunContext(input.sessionId, finalClamped)
1107
- } catch { /* non-critical — main loop continues even if sync fails */ }
1435
+ // Project orchestrator state into RunContext only when the objective source is already durable.
1436
+ const shouldSyncDurableRunContext = finalClamped.objectiveSource === 'goal'
1437
+ || finalClamped.objectiveSource === 'working_state'
1438
+ || finalClamped.objectiveSource === 'run_context'
1439
+ if (shouldSyncDurableRunContext) {
1440
+ try {
1441
+ syncMainLoopToRunContext(input.sessionId, finalClamped)
1442
+ } catch { /* non-critical — main loop continues even if sync fails */ }
1443
+ }
1108
1444
 
1109
1445
  return followup
1110
1446
  }
@@ -89,14 +89,19 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
89
89
  return preflight.terminalResult
90
90
  }
91
91
 
92
- const streamResult = await executePreparedChatTurn({
93
- input,
94
- prepared: preparedTurn,
95
- partialPersistence,
96
- preflightToolRoutingResult: preflight?.directMemoryResult || null,
97
- })
92
+ let streamResult: Awaited<ReturnType<typeof executePreparedChatTurn>>
93
+ try {
94
+ streamResult = await executePreparedChatTurn({
95
+ input,
96
+ prepared: preparedTurn,
97
+ partialPersistence,
98
+ preflightToolRoutingResult: preflight?.directMemoryResult || null,
99
+ })
98
100
 
99
- await partialPersistence.awaitIdle()
101
+ await partialPersistence.awaitIdle()
102
+ } finally {
103
+ partialPersistence.stop()
104
+ }
100
105
 
101
106
  if (!streamResult.errorMessage) {
102
107
  markProviderSuccess(preparedTurn.providerType, preparedTurn.sessionForRun.credentialId)