@goodandready/dsh-goal 0.2.0 → 0.2.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.
package/lib/index.js CHANGED
@@ -1,980 +1,1960 @@
1
1
  import path from 'node:path';
2
+
2
3
  import os from 'node:os';
4
+
3
5
  import z from '@deepseek-ai/schemastery';
6
+
4
7
  import { GoalEngine, GoalState, MilestoneStatus, detectLanguage } from './goal-engine.js';
8
+
5
9
  import { parseGoalInput, executeGoalSlashCommand, createGoalUserMessage } from './command-handler.js';
6
10
 
11
+
12
+
7
13
  export const name = '@goodandready/dsh-goal';
14
+
8
15
  export const inject = ['webServer', 'settings'];
9
16
 
17
+
18
+
10
19
  const NS = 'dsh-goal';
11
20
 
21
+
22
+
12
23
  function sessionIdOf(invocationOrReq, fallback = 'default') {
24
+
13
25
  if (!invocationOrReq) return fallback;
26
+
14
27
  try {
28
+
15
29
  // 1. DSH invocation / event / turn
30
+
16
31
  if (invocationOrReq.sessionId) return String(invocationOrReq.sessionId);
32
+
17
33
  if (invocationOrReq.session) {
34
+
18
35
  return String(invocationOrReq.session.id || invocationOrReq.session.header?.id || fallback);
36
+
19
37
  }
38
+
20
39
  if (invocationOrReq.data?.sessionId) return String(invocationOrReq.data.sessionId);
40
+
21
41
  if (invocationOrReq.agent?.session) {
42
+
22
43
  return String(invocationOrReq.agent.session.id || invocationOrReq.agent.session.header?.id || fallback);
44
+
23
45
  }
46
+
24
47
  // 2. HTTP Request (req)
48
+
25
49
  if (invocationOrReq.headers) {
50
+
26
51
  const headerSid = invocationOrReq.headers['x-dsh-session-id'];
52
+
27
53
  if (headerSid) return String(headerSid);
54
+
28
55
  if (invocationOrReq.url) {
56
+
29
57
  const url = new URL(invocationOrReq.url, 'http://localhost');
58
+
30
59
  const querySid = url.searchParams.get('sessionId') || url.searchParams.get('session');
60
+
31
61
  if (querySid) return String(querySid);
62
+
32
63
  }
64
+
33
65
  }
66
+
34
67
  } catch (_) {}
68
+
35
69
  return fallback;
70
+
36
71
  }
37
72
 
73
+
74
+
38
75
  // Схема-функция: в rc.1 settings.register(NS, schema, { base }) ожидает
76
+
39
77
  // schemastery-схему вторым аргументом.
78
+
40
79
  // Issue #24: storagePath объявлен в схеме конфигурации плагина
80
+
41
81
  export const Config = z.object({
82
+
42
83
  maxIterations: z.number().default(25).description('Safety limit: max autonomous iterations per goal'),
84
+
43
85
  autoDrive: z.boolean().default(true).description('Automatically continue the loop after each turn'),
86
+
44
87
  enableSound: z.boolean().default(true).description('Play synthesized audio chime on goal completion or failure'),
88
+
45
89
  showQuickLaunchButton: z.boolean().default(true).description('Show quick launch goal button above composer dock'),
90
+
46
91
  consecutiveToolFailureLimit: z.number().default(3).description('Auto-pause goal if N consecutive turns encounter tool execution errors (0 to disable)'),
92
+
47
93
  enableBrowserNotifications: z.boolean().default(true).description('Show desktop notifications on goal completion or failure'),
94
+
48
95
  storagePath: z.string().default('').description('Custom filesystem path for persistent state storage'),
96
+
49
97
  });
50
98
 
99
+
100
+
51
101
  export function apply(ctx, config = {}) {
102
+
52
103
  let settingsScope = null;
104
+
53
105
  let agentsService = null;
106
+
54
107
  const runningAgents = new Set();
108
+
55
109
  const sessionAgents = new Map(); // sessionId -> agent
110
+
56
111
  let lastActiveAgent = null;
57
112
 
113
+
114
+
58
115
  // SSE clients: sid -> Set of res objects
116
+
59
117
  const sseClients = new Map();
60
118
 
119
+
120
+
61
121
  let currentSettings = {
122
+
62
123
  maxIterations: config?.maxIterations ?? 25,
124
+
63
125
  autoDrive: config?.autoDrive ?? true,
126
+
64
127
  enableSound: config?.enableSound ?? true,
128
+
65
129
  showQuickLaunchButton: config?.showQuickLaunchButton ?? true,
130
+
66
131
  consecutiveToolFailureLimit: config?.consecutiveToolFailureLimit ?? 3,
132
+
67
133
  enableBrowserNotifications: config?.enableBrowserNotifications ?? true,
134
+
68
135
  storagePath: config?.storagePath,
136
+
69
137
  };
70
138
 
139
+
140
+
71
141
  const defaultStorageDir = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
142
+
72
143
  const storagePath = currentSettings.storagePath !== undefined && currentSettings.storagePath !== null
144
+
73
145
  ? currentSettings.storagePath
146
+
74
147
  : path.join(defaultStorageDir, 'dsh-goal-state.json');
75
148
 
149
+
150
+
76
151
  const engine = new GoalEngine({
152
+
77
153
  defaultMaxIterations: currentSettings.maxIterations,
154
+
78
155
  autoDrive: currentSettings.autoDrive,
156
+
79
157
  enableSound: currentSettings.enableSound,
158
+
80
159
  showQuickLaunchButton: currentSettings.showQuickLaunchButton,
160
+
81
161
  consecutiveToolFailureLimit: currentSettings.consecutiveToolFailureLimit,
162
+
82
163
  storagePath,
164
+
83
165
  });
84
166
 
167
+
168
+
85
169
  // Subscribe to engine changes for realtime Server-Sent Events broadcasting
170
+
86
171
  engine.subscribe((snapshot, sid) => {
172
+
87
173
  const clients = sseClients.get(sid);
174
+
88
175
  if (clients && clients.size > 0) {
176
+
89
177
  const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
178
+
90
179
  for (const clientRes of clients) {
180
+
91
181
  try {
182
+
92
183
  clientRes.write(payload);
184
+
93
185
  } catch (_) {}
186
+
94
187
  }
188
+
95
189
  }
190
+
96
191
  if (sid !== 'default' && sseClients.has('default')) {
192
+
97
193
  const defClients = sseClients.get('default');
194
+
98
195
  if (defClients && defClients.size > 0) {
196
+
99
197
  const payload = `data: ${JSON.stringify(snapshot)}\n\n`;
198
+
100
199
  for (const clientRes of defClients) {
200
+
101
201
  try {
202
+
102
203
  clientRes.write(payload);
204
+
103
205
  } catch (_) {}
206
+
104
207
  }
208
+
105
209
  }
210
+
106
211
  }
212
+
107
213
  });
108
214
 
215
+
216
+
109
217
  // Динамическое внедрение сервиса agents для управления жизненным циклом
218
+
110
219
  ctx.inject(['agents'], (actx) => {
220
+
111
221
  agentsService = actx.agents;
222
+
112
223
  });
113
224
 
225
+
226
+
114
227
  // Отслеживание выполняющихся агентов через глобальное событие ядра DSH
228
+
115
229
  ctx.on?.('agent/status', ({ agent, status }) => {
230
+
116
231
  if (status === 'running') {
232
+
117
233
  runningAgents.add(agent);
234
+
118
235
  lastActiveAgent = agent;
236
+
119
237
  const sid = sessionIdOf(agent, null);
238
+
120
239
  if (sid) {
240
+
121
241
  sessionAgents.set(sid, agent);
242
+
122
243
  }
244
+
123
245
  } else {
246
+
124
247
  runningAgents.delete(agent);
248
+
125
249
  if (lastActiveAgent === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
250
+
126
251
  lastActiveAgent = null;
252
+
127
253
  }
254
+
128
255
  const sid = sessionIdOf(agent, null);
256
+
129
257
  if (sid && sessionAgents.get(sid) === agent && (status === 'stopped' || status === 'completed' || status === 'error')) {
258
+
130
259
  sessionAgents.delete(sid);
260
+
131
261
  }
262
+
132
263
  }
264
+
133
265
  });
134
266
 
267
+
268
+
135
269
  const stopRunningAgents = (sessionId = 'default') => {
270
+
136
271
  const sid = sessionId || 'default';
272
+
137
273
  const specificAgent = sessionAgents.get(sid);
274
+
138
275
  if (specificAgent && typeof specificAgent.cancel === 'function') {
276
+
139
277
  try {
278
+
140
279
  specificAgent.cancel({ kind: 'user' });
280
+
141
281
  } catch (err) {
282
+
142
283
  console.warn('[dsh-goal] Failed to cancel specific agent for session:', err);
284
+
143
285
  }
286
+
144
287
  }
145
288
 
289
+
290
+
146
291
  // Отмена всех подходящих агентов в runningAgents
292
+
147
293
  for (const ag of runningAgents) {
294
+
148
295
  try {
296
+
149
297
  const agSid = sessionIdOf(ag, null);
298
+
150
299
  if (!agSid || agSid === sid || sid === 'default') {
300
+
151
301
  if (typeof ag.cancel === 'function') {
302
+
152
303
  ag.cancel({ kind: 'user' });
304
+
153
305
  }
306
+
154
307
  }
308
+
155
309
  } catch (err) {
310
+
156
311
  console.warn('[dsh-goal] Failed to cancel running agent:', err);
312
+
157
313
  }
314
+
158
315
  }
159
316
 
317
+
318
+
160
319
  if (lastActiveAgent && lastActiveAgent.status === 'running' && typeof lastActiveAgent.cancel === 'function') {
320
+
161
321
  const lastSid = sessionIdOf(lastActiveAgent, null);
322
+
162
323
  if (!lastSid || lastSid === sid || sid === 'default') {
324
+
163
325
  try {
326
+
164
327
  lastActiveAgent.cancel({ kind: 'user' });
328
+
165
329
  } catch (err) {
330
+
166
331
  console.warn('[dsh-goal] Failed to cancel lastActiveAgent:', err);
332
+
167
333
  }
334
+
168
335
  }
336
+
169
337
  }
338
+
170
339
  };
171
340
 
341
+
342
+
172
343
  const resumeActiveAgent = (promptText, sessionId = 'default') => {
344
+
173
345
  const sid = sessionId || 'default';
346
+
174
347
  const snap = engine.getSnapshot(sid);
348
+
175
349
  const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
350
+
176
351
  const defaultResumePrompt = lang === 'ru'
352
+
177
353
  ? '▶️ Цель возобновлена пользователем. Продолжай выполнение плана работ с того места, где остановился. Отмечай шаги через goal_update_progress.'
354
+
178
355
  : '▶️ Goal resumed by user. Continue executing the work plan from where you stopped. Update steps via goal_update_progress.';
356
+
179
357
  const resumeMsg = createGoalUserMessage(promptText || defaultResumePrompt);
358
+
180
359
  let target = sessionAgents.get(sid);
360
+
181
361
  if (!target && lastActiveAgent && typeof lastActiveAgent.followup === 'function') {
362
+
182
363
  const lastSid = sessionIdOf(lastActiveAgent, null);
364
+
183
365
  if (!lastSid || lastSid === sid || sid === 'default') {
366
+
184
367
  target = lastActiveAgent;
368
+
185
369
  }
370
+
186
371
  }
372
+
187
373
  if (!target && agentsService && typeof agentsService.list === 'function') {
374
+
188
375
  const list = agentsService.list();
376
+
189
377
  if (list && list.length > 0) {
378
+
190
379
  for (let i = list.length - 1; i >= 0; i--) {
380
+
191
381
  const cand = list[i];
382
+
192
383
  const candSid = sessionIdOf(cand, null);
384
+
193
385
  if (candSid === sid) {
386
+
194
387
  target = cand;
388
+
195
389
  break;
390
+
196
391
  }
392
+
197
393
  }
394
+
198
395
  if (!target && sid === 'default') {
396
+
199
397
  target = list[list.length - 1];
398
+
200
399
  }
400
+
201
401
  }
402
+
202
403
  }
203
404
 
405
+
406
+
204
407
  if (target && typeof target.followup === 'function') {
408
+
205
409
  try {
410
+
206
411
  target.followup(resumeMsg);
412
+
207
413
  lastActiveAgent = target;
414
+
208
415
  sessionAgents.set(sid, target);
416
+
209
417
  return true;
418
+
210
419
  } catch (err) {
420
+
211
421
  console.warn('[dsh-goal] Failed to resume agent:', err);
422
+
212
423
  }
424
+
213
425
  }
426
+
214
427
  return false;
428
+
215
429
  };
216
430
 
431
+
432
+
217
433
  // Issue #21: getConfig reads snap.value ONLY when status is 'ready' (or status is undefined in unit test mocks)
434
+
218
435
  const getConfig = () => {
436
+
219
437
  if (settingsScope) {
438
+
220
439
  const snap = typeof settingsScope.getSnapshot === 'function' ? settingsScope.getSnapshot() : null;
440
+
221
441
  if (snap) {
442
+
222
443
  if (snap.status === 'ready' || snap.status === undefined) {
444
+
223
445
  const live = snap.value || (typeof settingsScope.get === 'function' ? settingsScope.get() : null);
446
+
224
447
  if (live && typeof live === 'object') {
448
+
225
449
  return {
450
+
226
451
  maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
452
+
227
453
  autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
454
+
228
455
  enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
456
+
229
457
  showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
458
+
230
459
  consecutiveToolFailureLimit: typeof live.consecutiveToolFailureLimit === 'number' ? live.consecutiveToolFailureLimit : currentSettings.consecutiveToolFailureLimit,
460
+
231
461
  enableBrowserNotifications: typeof live.enableBrowserNotifications === 'boolean' ? live.enableBrowserNotifications : currentSettings.enableBrowserNotifications,
462
+
232
463
  storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
464
+
233
465
  };
466
+
234
467
  }
468
+
235
469
  }
470
+
236
471
  } else if (typeof settingsScope.get === 'function') {
472
+
237
473
  const live = settingsScope.get();
474
+
238
475
  if (live && typeof live === 'object') {
476
+
239
477
  return {
478
+
240
479
  maxIterations: typeof live.maxIterations === 'number' ? live.maxIterations : currentSettings.maxIterations,
480
+
241
481
  autoDrive: typeof live.autoDrive === 'boolean' ? live.autoDrive : currentSettings.autoDrive,
482
+
242
483
  enableSound: typeof live.enableSound === 'boolean' ? live.enableSound : currentSettings.enableSound,
484
+
243
485
  showQuickLaunchButton: typeof live.showQuickLaunchButton === 'boolean' ? live.showQuickLaunchButton : currentSettings.showQuickLaunchButton,
486
+
244
487
  storagePath: typeof live.storagePath === 'string' ? live.storagePath : currentSettings.storagePath,
488
+
245
489
  };
490
+
246
491
  }
492
+
247
493
  }
494
+
248
495
  }
496
+
249
497
  return currentSettings;
498
+
250
499
  };
251
500
 
501
+
502
+
252
503
  const applySettings = () => {
504
+
253
505
  const live = getConfig();
506
+
254
507
  engine.updateConfig({
508
+
255
509
  defaultMaxIterations: live.maxIterations,
510
+
256
511
  autoDrive: live.autoDrive,
512
+
257
513
  enableSound: live.enableSound,
514
+
258
515
  showQuickLaunchButton: live.showQuickLaunchButton,
516
+
259
517
  consecutiveToolFailureLimit: live.consecutiveToolFailureLimit,
518
+
260
519
  });
520
+
261
521
  };
262
522
 
523
+
524
+
263
525
  // 1. Регистрация настроек плагина
526
+
264
527
  ctx.inject(['settings'], (sctx) => {
528
+
265
529
  try {
530
+
266
531
  const scope = sctx.settings?.register?.(NS, Config, { base: currentSettings });
532
+
267
533
  if (scope) {
534
+
268
535
  settingsScope = scope;
536
+
269
537
  applySettings();
538
+
270
539
  if (typeof scope.subscribe === 'function') {
540
+
271
541
  sctx.effect(() => {
542
+
272
543
  const off = scope.subscribe(() => {
544
+
273
545
  applySettings();
546
+
274
547
  });
548
+
275
549
  return () => {
550
+
276
551
  if (typeof off === 'function') off();
552
+
277
553
  settingsScope = null;
554
+
278
555
  };
556
+
279
557
  }, 'dsh-goal: settings subscription');
558
+
280
559
  }
560
+
281
561
  }
562
+
282
563
  } catch (err) {
564
+
283
565
  console.warn('[dsh-goal] Settings register skipped:', err.message);
566
+
284
567
  }
568
+
285
569
  });
286
570
 
571
+
572
+
287
573
  // 2. Регистрация слэш-команды /goal в чате
574
+
288
575
  ctx.inject(['commands'], (cctx) => {
576
+
289
577
  try {
578
+
290
579
  if (typeof cctx.commands?.register !== 'function') return;
291
580
 
581
+
582
+
292
583
  const unregister = cctx.commands.register({
584
+
293
585
  name: 'goal',
294
- description: 'Управление режимом цели (Goal Mode): активация, вехи, пауза, сброс',
295
- input: { hint: '[<цель>|clear|pause|resume]' },
586
+
587
+ description: 'Manage Goal Mode: activate, milestones, pause, resume, and clear',
588
+
589
+ input: { hint: '[<objective>|clear|pause|resume]' },
590
+
296
591
  handler: async (invocation) => {
592
+
297
593
  const sid = sessionIdOf(invocation, 'default');
594
+
298
595
  if (invocation?.agent) {
596
+
299
597
  lastActiveAgent = invocation.agent;
598
+
300
599
  sessionAgents.set(sid, invocation.agent);
600
+
301
601
  }
602
+
302
603
  const parsed = parseGoalInput(invocation?.rawInput);
604
+
303
605
  const liveConfig = getConfig();
606
+
304
607
  return executeGoalSlashCommand(engine, parsed, liveConfig, invocation?.agent, sid);
608
+
305
609
  },
610
+
306
611
  });
307
612
 
613
+
614
+
308
615
  if (typeof unregister === 'function') {
616
+
309
617
  cctx.effect(() => () => unregister(), 'dsh-goal: /goal command unregister');
618
+
310
619
  }
620
+
311
621
  } catch (err) {
622
+
312
623
  console.warn('[dsh-goal] Commands register skipped:', err.message);
624
+
313
625
  }
626
+
314
627
  });
315
628
 
629
+
630
+
316
631
  // 3. Инжекция контекста цели в системный промпт (systemPrompt)
632
+
317
633
  ctx.inject(['systemPrompt'], (pctx) => {
634
+
318
635
  try {
636
+
319
637
  if (typeof pctx.systemPrompt?.section === 'function') {
638
+
320
639
  // Clear conflicting core tool:goal prompt section if present
640
+
321
641
  const globalSections = pctx.systemPrompt.layers?.global?.sections;
642
+
322
643
  if (globalSections?.data instanceof Map && globalSections.data.has('tool:goal')) {
644
+
323
645
  globalSections.data.delete('tool:goal');
646
+
324
647
  }
325
648
 
649
+
650
+
326
651
  const order = typeof pctx.systemPrompt.getSectionOrder === 'function'
652
+
327
653
  ? (pctx.systemPrompt.getSectionOrder('TOOL_GOAL') || 2400)
654
+
328
655
  : 2400;
329
656
 
657
+
658
+
330
659
  const unregisterSection = pctx.systemPrompt.section({
660
+
331
661
  name: 'tool:dsh-goal',
662
+
332
663
  order,
664
+
333
665
  text: (sessionCtx) => {
666
+
334
667
  const sid = sessionIdOf(sessionCtx, 'default');
668
+
335
669
  return engine.getStatePromptInjection(sid);
670
+
336
671
  },
672
+
337
673
  });
338
674
 
675
+
676
+
339
677
  if (typeof unregisterSection === 'function') {
678
+
340
679
  pctx.effect(() => () => unregisterSection(), 'dsh-goal: system prompt section');
680
+
341
681
  }
682
+
342
683
  }
684
+
343
685
  } catch (err) {
686
+
344
687
  console.warn('[dsh-goal] SystemPrompt section register skipped:', err.message);
688
+
345
689
  }
690
+
346
691
  });
347
692
 
693
+
694
+
348
695
  // 4. Регистрация инструментов для модели (tools)
696
+
349
697
  ctx.inject(['tools'], (tctx) => {
698
+
350
699
  if (!tctx.tools?.register) return;
351
700
 
701
+
702
+
352
703
  // Helper: Safely replace or register tool in ToolRuntime
704
+
353
705
  const safeRegister = (definition) => {
706
+
354
707
  try {
708
+
355
709
  const name = definition.name;
710
+
356
711
  const globalTools = tctx.tools.layers?.global?.tools;
712
+
357
713
  if (globalTools?.data instanceof Map && globalTools.data.has(name)) {
714
+
358
715
  globalTools.data.delete(name);
716
+
359
717
  }
718
+
360
719
  const unregister = tctx.tools.register(definition);
720
+
361
721
  if (typeof unregister === 'function') {
722
+
362
723
  tctx.effect(() => () => unregister(), `dsh-goal: tool ${name}`);
724
+
363
725
  }
726
+
364
727
  } catch (err) {
728
+
365
729
  console.warn(`[dsh-goal] Tool ${definition.name} registration skipped:`, err.message);
730
+
366
731
  }
732
+
367
733
  };
368
734
 
735
+
736
+
369
737
  const JSON_OUTPUT = {
738
+
370
739
  schema: { type: 'object', additionalProperties: true },
740
+
371
741
  render: (_args, val) => [{ type: 'text', text: JSON.stringify(val) }],
742
+
372
743
  };
373
744
 
745
+
746
+
374
747
  function formatGoalValue(snap) {
748
+
375
749
  if (!snap || !snap.hasActiveGoal) {
750
+
376
751
  return { goal: null };
752
+
377
753
  }
754
+
378
755
  let phase = 'active';
756
+
379
757
  if (snap.state === GoalState.PAUSED) phase = 'paused';
758
+
380
759
  else if (snap.state === GoalState.COMPLETED) phase = 'complete';
381
760
 
761
+
762
+
382
763
  const roundsStarted = Number.isInteger(snap.iterations) ? snap.iterations : 0;
764
+
383
765
  const maxGoalRounds = Number.isInteger(snap.maxIterations) ? snap.maxIterations : 25;
384
766
 
767
+
768
+
385
769
  return {
770
+
386
771
  goal: {
772
+
387
773
  id: snap.id || 'goal-active',
774
+
388
775
  revision: 1,
776
+
389
777
  objective: snap.title || 'Goal',
778
+
390
779
  phase,
780
+
391
781
  roundsStarted,
782
+
392
783
  maxGoalRounds,
784
+
393
785
  },
786
+
394
787
  activation: snap.state === GoalState.RUNNING ? 'armed' : 'disarmed',
788
+
395
789
  };
790
+
396
791
  }
397
792
 
793
+
794
+
398
795
  // Совместимый инструмент 1: get_goal
796
+
399
797
  const handleGetGoal = async (_args, toolCtx) => {
798
+
400
799
  const sid = sessionIdOf(toolCtx, 'default');
800
+
401
801
  const snap = engine.getSnapshot(sid);
802
+
402
803
  return formatGoalValue(snap);
804
+
403
805
  };
404
806
 
807
+
808
+
405
809
  safeRegister({
810
+
406
811
  name: 'get_goal',
812
+
407
813
  description: 'Read the current same-session goal, including objective, phase, and round limits.',
814
+
408
815
  parameters: { type: 'object', properties: {} },
816
+
409
817
  output: JSON_OUTPUT,
818
+
410
819
  execute: handleGetGoal,
820
+
411
821
  handler: handleGetGoal,
822
+
412
823
  });
413
824
 
825
+
826
+
414
827
  // Совместимый инструмент 2: create_goal
828
+
415
829
  const handleCreateGoal = async (args, toolCtx) => {
830
+
416
831
  const sid = sessionIdOf(toolCtx, 'default');
832
+
417
833
  const maxIterations = Number(args.max_goal_rounds) || 25;
834
+
418
835
  const snap = engine.startGoal(args.objective, { maxIterations }, sid);
836
+
419
837
  return formatGoalValue(snap);
838
+
420
839
  };
421
840
 
841
+
842
+
422
843
  safeRegister({
844
+
423
845
  name: 'create_goal',
846
+
424
847
  description: 'Create one persisted same-session completion goal for long-running autonomous work.',
848
+
425
849
  parameters: {
850
+
426
851
  type: 'object',
852
+
427
853
  properties: {
854
+
428
855
  objective: {
856
+
429
857
  type: 'string',
858
+
430
859
  description: 'The concrete completion objective.',
860
+
431
861
  },
862
+
432
863
  max_goal_rounds: {
864
+
433
865
  type: 'number',
866
+
434
867
  description: 'Optional positive integer limit on automatic continuation rounds.',
868
+
435
869
  },
870
+
436
871
  },
872
+
437
873
  required: ['objective'],
874
+
438
875
  },
876
+
439
877
  output: JSON_OUTPUT,
878
+
440
879
  execute: handleCreateGoal,
880
+
441
881
  handler: handleCreateGoal,
882
+
442
883
  });
443
884
 
885
+
886
+
444
887
  // Совместимый инструмент 3: update_goal (перехватчик authority checks и роутер в GoalEngine)
888
+
445
889
  const handleUpdateGoal = async (args, toolCtx) => {
890
+
446
891
  const sid = sessionIdOf(toolCtx, 'default');
892
+
447
893
  const action = args.action;
448
894
 
895
+
896
+
449
897
  if (action === 'complete') {
898
+
450
899
  const summary = args.blocked_reason || args.objective || 'Goal marked complete';
900
+
451
901
  const snap = engine.completeGoal(summary, sid);
902
+
452
903
  if (toolCtx?.deferContext) {
904
+
453
905
  try {
906
+
454
907
  toolCtx.deferContext({
908
+
455
909
  type: 'text',
910
+
456
911
  text: `<goal_complete>\nObjective: ${JSON.stringify(snap.title || summary)}\nThe goal is marked complete. Summarize what was accomplished for the user.\n</goal_complete>`,
912
+
457
913
  });
914
+
458
915
  } catch (_) {}
916
+
459
917
  }
918
+
460
919
  return formatGoalValue(snap);
920
+
461
921
  }
462
922
 
923
+
924
+
463
925
  if (action === 'pause') {
926
+
464
927
  const reason = args.blocked_reason || 'Paused by model';
928
+
465
929
  const snap = engine.pause(reason, sid);
930
+
466
931
  return formatGoalValue(snap);
932
+
467
933
  }
468
934
 
935
+
936
+
469
937
  if (action === 'resume') {
938
+
470
939
  const snap = engine.resume(sid);
940
+
471
941
  resumeActiveAgent(undefined, sid);
942
+
472
943
  return formatGoalValue(snap);
944
+
473
945
  }
474
946
 
947
+
948
+
475
949
  if (action === 'edit') {
950
+
476
951
  const snap = engine.getSnapshot(sid);
952
+
477
953
  if (args.objective) snap.title = args.objective;
954
+
478
955
  if (args.max_goal_rounds) snap.maxIterations = Number(args.max_goal_rounds);
956
+
479
957
  engine.emit(sid, true);
958
+
480
959
  return formatGoalValue(snap);
960
+
481
961
  }
482
962
 
963
+
964
+
483
965
  if (action === 'blocked') {
966
+
484
967
  const reason = args.blocked_reason || 'Goal blocked';
968
+
485
969
  const snap = engine.pause('Blocked: ' + reason, sid);
970
+
486
971
  if (toolCtx?.deferContext) {
972
+
487
973
  try {
974
+
488
975
  toolCtx.deferContext({
976
+
489
977
  type: 'text',
978
+
490
979
  text: `<goal_blocked>\nObjective: ${JSON.stringify(snap.title || 'Goal')}\nBlocked: ${JSON.stringify(reason)}\nExplain to the user what blocked progress.\n</goal_blocked>`,
980
+
491
981
  });
982
+
492
983
  } catch (_) {}
984
+
493
985
  }
986
+
494
987
  const res = formatGoalValue(snap);
988
+
495
989
  if (res.goal) {
990
+
496
991
  res.goal.phase = 'blocked';
992
+
497
993
  res.goal.blockedReason = { code: 'model-reported', message: reason };
994
+
498
995
  }
996
+
499
997
  return res;
998
+
500
999
  }
501
1000
 
1001
+
1002
+
502
1003
  return formatGoalValue(engine.getSnapshot(sid));
1004
+
503
1005
  };
504
1006
 
1007
+
1008
+
505
1009
  safeRegister({
1010
+
506
1011
  name: 'update_goal',
1012
+
507
1013
  description: 'Update the active goal: complete, pause, resume, edit, or report blocked.',
1014
+
508
1015
  parameters: {
1016
+
509
1017
  type: 'object',
1018
+
510
1019
  properties: {
1020
+
511
1021
  goal_id: { type: 'string', description: 'Exact id returned by get_goal.' },
1022
+
512
1023
  revision: { type: 'number', description: 'Exact positive revision returned by get_goal.' },
1024
+
513
1025
  action: {
1026
+
514
1027
  type: 'string',
1028
+
515
1029
  enum: ['edit', 'pause', 'resume', 'complete', 'blocked'],
1030
+
516
1031
  description: 'edit | pause | resume | complete | blocked',
1032
+
517
1033
  },
1034
+
518
1035
  objective: { type: 'string', description: 'Replacement objective; valid with action edit.' },
1036
+
519
1037
  max_goal_rounds: { type: 'number', description: 'Replacement cap; valid with action edit.' },
1038
+
520
1039
  blocked_reason: { type: 'string', description: 'Concrete blocking condition; required with action blocked.' },
1040
+
521
1041
  },
1042
+
522
1043
  required: ['action'],
1044
+
523
1045
  },
1046
+
524
1047
  output: JSON_OUTPUT,
1048
+
525
1049
  execute: handleUpdateGoal,
1050
+
526
1051
  handler: handleUpdateGoal,
1052
+
527
1053
  });
528
1054
 
1055
+
1056
+
529
1057
  // Инструмент 4: Декомпозиция цели на вехи
1058
+
530
1059
  const handleSetMilestones = async ({ milestones }, toolCtx) => {
1060
+
531
1061
  const sid = sessionIdOf(toolCtx, 'default');
1062
+
532
1063
  const snap = engine.getSnapshot(sid);
1064
+
533
1065
  if (!snap.hasActiveGoal) {
1066
+
534
1067
  return { error: 'No active goal currently set. Start a goal first.' };
1068
+
535
1069
  }
1070
+
536
1071
  engine.addMilestones(milestones, true, sid);
1072
+
537
1073
  return {
1074
+
538
1075
  success: true,
1076
+
539
1077
  milestones: engine.getSnapshot(sid).milestones,
1078
+
540
1079
  };
1080
+
541
1081
  };
542
1082
 
1083
+
1084
+
543
1085
  safeRegister({
1086
+
544
1087
  name: 'goal_set_milestones',
1088
+
545
1089
  description: 'Break down the current active goal into a sequence of concrete milestones/sub-tasks.',
1090
+
546
1091
  parameters: {
1092
+
547
1093
  type: 'object',
1094
+
548
1095
  properties: {
1096
+
549
1097
  milestones: {
1098
+
550
1099
  type: 'array',
1100
+
551
1101
  items: { type: 'string' },
1102
+
552
1103
  description: 'List of milestone titles to accomplish.',
1104
+
553
1105
  },
1106
+
554
1107
  },
1108
+
555
1109
  required: ['milestones'],
1110
+
556
1111
  },
1112
+
557
1113
  output: JSON_OUTPUT,
1114
+
558
1115
  execute: handleSetMilestones,
1116
+
559
1117
  handler: handleSetMilestones,
1118
+
560
1119
  });
561
1120
 
1121
+
1122
+
562
1123
  // Инструмент 5: Обновление статуса вехи
1124
+
563
1125
  const handleUpdateProgress = async ({ milestone_id, status, notes }, toolCtx) => {
1126
+
564
1127
  const sid = sessionIdOf(toolCtx, 'default');
1128
+
565
1129
  const ok = engine.updateMilestone(milestone_id, status, notes, sid);
1130
+
566
1131
  if (!ok) {
1132
+
567
1133
  return { error: `Milestone ${milestone_id} not found or no active goal.` };
1134
+
568
1135
  }
1136
+
569
1137
  return {
1138
+
570
1139
  success: true,
1140
+
571
1141
  snapshot: engine.getSnapshot(sid),
1142
+
572
1143
  };
1144
+
573
1145
  };
574
1146
 
1147
+
1148
+
575
1149
  safeRegister({
1150
+
576
1151
  name: 'goal_update_progress',
1152
+
577
1153
  description: 'Update the status of a specific goal milestone and optionally log progress notes.',
1154
+
578
1155
  parameters: {
1156
+
579
1157
  type: 'object',
1158
+
580
1159
  properties: {
1160
+
581
1161
  milestone_id: {
1162
+
582
1163
  type: 'string',
1164
+
583
1165
  description: 'The ID of the milestone (e.g. "m-1", "m-2").',
1166
+
584
1167
  },
1168
+
585
1169
  status: {
1170
+
586
1171
  type: 'string',
1172
+
587
1173
  enum: ['pending', 'in_progress', 'completed', 'failed'],
1174
+
588
1175
  description: 'New status for this milestone.',
1176
+
589
1177
  },
1178
+
590
1179
  notes: {
1180
+
591
1181
  type: 'string',
1182
+
592
1183
  description: 'Brief summary of what was accomplished or why it failed.',
1184
+
593
1185
  },
1186
+
594
1187
  },
1188
+
595
1189
  required: ['milestone_id', 'status'],
1190
+
596
1191
  },
1192
+
597
1193
  output: JSON_OUTPUT,
1194
+
598
1195
  execute: handleUpdateProgress,
1196
+
599
1197
  handler: handleUpdateProgress,
1198
+
600
1199
  });
601
1200
 
1201
+
1202
+
602
1203
  // Инструмент 6: Успешное завершение цели
1204
+
603
1205
  const handleGoalFinish = async ({ summary }, toolCtx) => {
1206
+
604
1207
  const sid = sessionIdOf(toolCtx, 'default');
1208
+
605
1209
  const snap = engine.completeGoal(summary, sid);
1210
+
606
1211
  return {
1212
+
607
1213
  success: true,
1214
+
608
1215
  completed: true,
1216
+
609
1217
  summary,
1218
+
610
1219
  };
1220
+
611
1221
  };
612
1222
 
1223
+
1224
+
613
1225
  safeRegister({
1226
+
614
1227
  name: 'goal_finish',
1228
+
615
1229
  description: 'Conclude the active goal successfully with a final summary and achievements.',
1230
+
616
1231
  parameters: {
1232
+
617
1233
  type: 'object',
1234
+
618
1235
  properties: {
1236
+
619
1237
  summary: {
1238
+
620
1239
  type: 'string',
1240
+
621
1241
  description: 'Final summary of the goal outcome and deliverables.',
1242
+
622
1243
  },
1244
+
623
1245
  },
1246
+
624
1247
  required: ['summary'],
1248
+
625
1249
  },
1250
+
626
1251
  output: JSON_OUTPUT,
1252
+
627
1253
  execute: handleGoalFinish,
1254
+
628
1255
  handler: handleGoalFinish,
1256
+
629
1257
  });
1258
+
630
1259
  });
631
1260
 
1261
+
1262
+
632
1263
  // 5. Регистрация HTTP REST API маршрутов и SSE событий
1264
+
633
1265
  ctx.effect(() => {
1266
+
634
1267
  if (!ctx.webServer?.register) return () => {};
635
1268
 
1269
+
1270
+
636
1271
  // Keepalive ping timer for SSE connections (every 20s)
1272
+
637
1273
  const keepaliveTimer = setInterval(() => {
1274
+
638
1275
  for (const clients of sseClients.values()) {
1276
+
639
1277
  for (const res of clients) {
1278
+
640
1279
  try {
1280
+
641
1281
  res.write(': keepalive\n\n');
1282
+
642
1283
  } catch (_) {}
1284
+
643
1285
  }
1286
+
644
1287
  }
1288
+
645
1289
  }, 20000);
1290
+
646
1291
  if (typeof keepaliveTimer.unref === 'function') keepaliveTimer.unref();
647
1292
 
1293
+
1294
+
648
1295
  const unreg = ctx.webServer.register({
1296
+
649
1297
  kind: 'prefix',
1298
+
650
1299
  path: '/dsh-goal',
1300
+
651
1301
  handler: (req, res) => {
1302
+
652
1303
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1304
+
653
1305
  const pathname = url.pathname;
654
1306
 
1307
+
1308
+
655
1309
  // GET /dsh-goal/events — Server-Sent Events realtime snapshot stream
1310
+
656
1311
  if (req.method === 'GET' && (pathname === '/dsh-goal/events' || pathname === '/dsh-goal/events/')) {
1312
+
657
1313
  const sid = sessionIdOf(req, 'default');
1314
+
658
1315
  res.writeHead(200, {
1316
+
659
1317
  'Content-Type': 'text/event-stream',
1318
+
660
1319
  'Cache-Control': 'no-cache, no-transform',
1320
+
661
1321
  'Connection': 'keep-alive',
1322
+
662
1323
  'X-Accel-Buffering': 'no',
1324
+
663
1325
  });
664
1326
 
1327
+
1328
+
665
1329
  if (!sseClients.has(sid)) {
1330
+
666
1331
  sseClients.set(sid, new Set());
1332
+
667
1333
  }
1334
+
668
1335
  sseClients.get(sid).add(res);
669
1336
 
1337
+
1338
+
670
1339
  const initialSnap = engine.getSnapshot(sid);
1340
+
671
1341
  res.write(`data: ${JSON.stringify(initialSnap)}\n\n`);
672
1342
 
1343
+
1344
+
673
1345
  req.on('close', () => {
1346
+
674
1347
  const set = sseClients.get(sid);
1348
+
675
1349
  if (set) {
1350
+
676
1351
  set.delete(res);
1352
+
677
1353
  if (set.size === 0) sseClients.delete(sid);
1354
+
678
1355
  }
1356
+
679
1357
  });
1358
+
680
1359
  return;
1360
+
681
1361
  }
682
1362
 
1363
+
1364
+
683
1365
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
684
1366
 
1367
+
1368
+
685
1369
  // GET /dsh-goal/state
1370
+
686
1371
  if (req.method === 'GET' && (pathname === '/dsh-goal/state' || pathname === '/dsh-goal/state/')) {
1372
+
687
1373
  const sid = sessionIdOf(req, 'default');
1374
+
688
1375
  res.statusCode = 200;
1376
+
689
1377
  return res.end(JSON.stringify(engine.getSnapshot(sid)));
1378
+
690
1379
  }
691
1380
 
1381
+
1382
+
692
1383
  // POST /dsh-goal/action
1384
+
693
1385
  // Issue #22: CSRF & Same-Origin guard
1386
+
694
1387
  if (req.method === 'POST' && (pathname === '/dsh-goal/action' || pathname === '/dsh-goal/action/')) {
1388
+
695
1389
  const secFetchSite = req.headers['sec-fetch-site'];
1390
+
696
1391
  if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'same-site' && secFetchSite !== 'none') {
1392
+
697
1393
  res.statusCode = 403;
1394
+
698
1395
  return res.end(JSON.stringify({ error: 'Forbidden: cross-site requests are rejected' }));
1396
+
699
1397
  }
700
1398
 
1399
+
1400
+
701
1401
  const origin = req.headers.origin;
1402
+
702
1403
  const host = req.headers.host;
1404
+
703
1405
  if (origin && host) {
1406
+
704
1407
  try {
1408
+
705
1409
  const originHost = new URL(origin).host;
1410
+
706
1411
  if (originHost !== host) {
1412
+
707
1413
  res.statusCode = 403;
1414
+
708
1415
  return res.end(JSON.stringify({ error: 'Forbidden: origin mismatch' }));
1416
+
709
1417
  }
1418
+
710
1419
  } catch (_) {
1420
+
711
1421
  res.statusCode = 403;
1422
+
712
1423
  return res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
1424
+
713
1425
  }
1426
+
714
1427
  }
715
1428
 
1429
+
1430
+
716
1431
  let body = '';
1432
+
717
1433
  let bodySize = 0;
1434
+
718
1435
  const MAX_PAYLOAD_BYTES = 256 * 1024;
1436
+
719
1437
  let limitExceeded = false;
720
1438
 
1439
+
1440
+
721
1441
  req.on('data', (chunk) => {
1442
+
722
1443
  bodySize += chunk.length;
1444
+
723
1445
  if (bodySize > MAX_PAYLOAD_BYTES) {
1446
+
724
1447
  limitExceeded = true;
1448
+
725
1449
  req.pause();
1450
+
726
1451
  res.statusCode = 413;
1452
+
727
1453
  return res.end(JSON.stringify({ error: 'Payload too large: max 256 KB allowed' }));
1454
+
728
1455
  }
1456
+
729
1457
  body += chunk;
1458
+
730
1459
  });
731
1460
 
1461
+
1462
+
732
1463
  req.on('end', () => {
1464
+
733
1465
  if (limitExceeded) return;
1466
+
734
1467
  try {
1468
+
735
1469
  const data = JSON.parse(body || '{}');
1470
+
736
1471
  const sid = data.sessionId || sessionIdOf(req, 'default');
1472
+
737
1473
  const { action, title, description, reason, milestoneId, status, notes } = data;
738
1474
 
1475
+
1476
+
739
1477
  let result = null;
1478
+
740
1479
  switch (action) {
1480
+
741
1481
  case 'start': {
1482
+
742
1483
  const cleanTitle = typeof title === 'string' ? title.trim() : '';
1484
+
743
1485
  if (!cleanTitle) {
1486
+
744
1487
  res.statusCode = 400;
1488
+
745
1489
  return res.end(JSON.stringify({ error: 'Goal title cannot be empty' }));
1490
+
746
1491
  }
1492
+
747
1493
  const detectedLang = data.lang || detectLanguage(cleanTitle);
1494
+
748
1495
  result = engine.startGoal(cleanTitle, {
1496
+
749
1497
  description: typeof description === 'string' ? description.trim() : '',
1498
+
750
1499
  maxIterations: getConfig().maxIterations,
1500
+
751
1501
  lang: detectedLang,
1502
+
752
1503
  }, sid);
1504
+
753
1505
  const startPrompt = detectedLang === 'ru'
1506
+
754
1507
  ? `🎯 Цель установлена: "${cleanTitle}". Немедленно сформируй план работ (3-7 конкретных шагов) через инструмент goal_set_milestones и начни его выполнение.`
1508
+
755
1509
  : `🎯 Goal established: "${cleanTitle}". Immediately formulate a work plan (3-7 concrete steps) via tool goal_set_milestones and start executing it.`;
1510
+
756
1511
  resumeActiveAgent(startPrompt, sid);
1512
+
757
1513
  break;
1514
+
758
1515
  }
1516
+
759
1517
  case 'nudge': {
1518
+
760
1519
  const text = (typeof data.text === 'string' ? data.text : (data.notes || data.nudge || '')).trim();
1520
+
761
1521
  if (!text) {
1522
+
762
1523
  res.statusCode = 400;
1524
+
763
1525
  return res.end(JSON.stringify({ error: 'Nudge text cannot be empty' }));
1526
+
764
1527
  }
1528
+
765
1529
  result = engine.nudge(text, sid);
1530
+
766
1531
  if (data.resume) {
1532
+
767
1533
  engine.resume(sid);
1534
+
768
1535
  const prompt = engine.getStatePromptInjection(sid);
1536
+
769
1537
  resumeActiveAgent(prompt, sid);
1538
+
770
1539
  }
1540
+
771
1541
  break;
1542
+
772
1543
  }
1544
+
773
1545
  case 'pause':
774
- result = engine.pause(reason || 'Пауза по кнопке интерфейса', sid);
1546
+
1547
+ result = engine.pause(reason || 'Paused by user interface', sid);
1548
+
775
1549
  stopRunningAgents(sid);
1550
+
776
1551
  break;
1552
+
777
1553
  case 'resume':
1554
+
778
1555
  result = engine.resume(sid);
1556
+
779
1557
  resumeActiveAgent(undefined, sid);
1558
+
780
1559
  break;
1560
+
781
1561
  case 'cancel':
782
- result = engine.cancel(reason || 'Отмена цели', sid);
1562
+
1563
+ result = engine.cancel(reason || 'Goal cancelled by user', sid);
1564
+
783
1565
  stopRunningAgents(sid);
1566
+
784
1567
  break;
1568
+
785
1569
  case 'clear':
1570
+
786
1571
  result = engine.clear(sid);
1572
+
787
1573
  stopRunningAgents(sid);
1574
+
788
1575
  sessionAgents.delete(sid);
1576
+
789
1577
  break;
1578
+
790
1579
  case 'update_milestone': {
1580
+
791
1581
  if (!milestoneId || !status) {
1582
+
792
1583
  res.statusCode = 400;
1584
+
793
1585
  return res.end(JSON.stringify({ error: 'milestoneId and status are required' }));
1586
+
794
1587
  }
1588
+
795
1589
  const validStatuses = Object.values(MilestoneStatus);
1590
+
796
1591
  if (!validStatuses.includes(status)) {
1592
+
797
1593
  res.statusCode = 400;
1594
+
798
1595
  return res.end(JSON.stringify({ error: `Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}` }));
1596
+
799
1597
  }
1598
+
800
1599
  const ok = engine.updateMilestone(milestoneId, status, notes, sid);
1600
+
801
1601
  if (!ok) {
1602
+
802
1603
  res.statusCode = 404;
1604
+
803
1605
  return res.end(JSON.stringify({ error: `Milestone with id "${milestoneId}" not found` }));
1606
+
804
1607
  }
1608
+
805
1609
  result = engine.getSnapshot(sid);
1610
+
806
1611
  break;
1612
+
807
1613
  }
1614
+
808
1615
  default:
1616
+
809
1617
  res.statusCode = 400;
1618
+
810
1619
  return res.end(JSON.stringify({ error: `Unknown action: ${action}` }));
1620
+
811
1621
  }
812
1622
 
1623
+
1624
+
813
1625
  res.statusCode = 200;
1626
+
814
1627
  return res.end(JSON.stringify({ ok: true, state: result || engine.getSnapshot(sid) }));
1628
+
815
1629
  } catch (parseErr) {
1630
+
816
1631
  res.statusCode = 400;
1632
+
817
1633
  return res.end(JSON.stringify({ error: parseErr.message }));
1634
+
818
1635
  }
1636
+
819
1637
  });
1638
+
820
1639
  return;
1640
+
821
1641
  }
822
1642
 
1643
+
1644
+
823
1645
  res.statusCode = 404;
1646
+
824
1647
  res.end(JSON.stringify({ error: 'Endpoint not found' }));
1648
+
825
1649
  },
1650
+
826
1651
  });
827
1652
 
1653
+
1654
+
828
1655
  return () => {
1656
+
829
1657
  clearInterval(keepaliveTimer);
1658
+
830
1659
  if (typeof unreg === 'function') unreg();
1660
+
831
1661
  for (const clients of sseClients.values()) {
1662
+
832
1663
  for (const res of clients) {
1664
+
833
1665
  try {
1666
+
834
1667
  res.end();
1668
+
835
1669
  } catch (_) {}
1670
+
836
1671
  }
1672
+
837
1673
  }
1674
+
838
1675
  sseClients.clear();
1676
+
839
1677
  };
1678
+
840
1679
  }, 'dsh-goal: HTTP WebServer Routes & SSE');
841
1680
 
1681
+
1682
+
842
1683
  // 6. Подписка на события сессии (автономный цикл)
1684
+
843
1685
  ctx.effect(() => {
1686
+
844
1687
  // Подписка на завершение turn
1688
+
845
1689
  const onTurnEnd = (turn) => {
1690
+
846
1691
  const liveConfig = getConfig();
1692
+
847
1693
  if (!liveConfig.autoDrive) return; // Учитываем настройку autoDrive в рантайме
848
1694
 
1695
+
1696
+
849
1697
  const sid = sessionIdOf(turn, 'default');
850
1698
 
1699
+
1700
+
851
1701
  // Накопление токенов по завершении хода
1702
+
852
1703
  const usage = turn?.usage || turn?.meta?.usage || turn?.response?.usage || turn?.turn?.usage;
1704
+
853
1705
  if (usage) {
1706
+
854
1707
  engine.addTokenUsage(usage, sid);
1708
+
855
1709
  }
856
1710
 
1711
+
1712
+
857
1713
  const snap = engine.getSnapshot(sid);
1714
+
858
1715
  if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
1716
+
859
1717
  // Tool-Failure Breaker: отслеживание повторяющихся ошибок инструментов
1718
+
860
1719
  const steps = turn?.steps || turn?.turn?.steps || [];
1720
+
861
1721
  const hasStepError = Array.isArray(steps) && steps.some((s) => {
1722
+
862
1723
  return s?.status === 'error' || s?.error || s?.toolResult?.isError;
1724
+
863
1725
  });
1726
+
864
1727
  const hasTurnError = Boolean(turn?.error || turn?.reason?.kind === 'error');
1728
+
865
1729
  const isToolFailure = hasStepError || hasTurnError;
866
1730
 
1731
+
1732
+
867
1733
  if (isToolFailure) {
1734
+
868
1735
  const failCount = engine.incrementToolFailureCount(sid);
1736
+
869
1737
  const limit = liveConfig.consecutiveToolFailureLimit;
1738
+
870
1739
  if (limit > 0 && failCount >= limit) {
1740
+
871
1741
  const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
1742
+
872
1743
  const pauseReason = lang === 'ru'
1744
+
873
1745
  ? `Повторяющаяся ошибка инструментов (${failCount} подряд) — цель приостановлена для защиты от зацикливания`
1746
+
874
1747
  : `Repeated tool failure (${failCount} consecutive) — goal paused to prevent token burn`;
1748
+
875
1749
  engine.pause(pauseReason, sid);
1750
+
876
1751
  return;
1752
+
877
1753
  }
1754
+
878
1755
  } else {
1756
+
879
1757
  engine.resetToolFailureCount(sid);
1758
+
880
1759
  }
881
1760
 
1761
+
1762
+
882
1763
  // Проверяем причину завершения хода
1764
+
883
1765
  if (turn?.reason?.kind === 'aborted' || turn?.reason?.kind === 'error' || turn?.error) {
1766
+
884
1767
  return;
1768
+
885
1769
  }
886
1770
 
1771
+
1772
+
887
1773
  // Item 3: Smart Progress Guard — detect idle loops without progress
1774
+
888
1775
  const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
1776
+
889
1777
  const stallCount = engine.incrementStallCount(sid);
1778
+
890
1779
  if (stallCount >= 2) {
1780
+
891
1781
  const stallReason = lang === 'ru'
1782
+
892
1783
  ? 'Агент не продвинулся по плану за последние 2 итерации — требуется внимание оператора'
1784
+
893
1785
  : 'Agent made no progress on work plan in the last 2 iterations — operator attention required';
1786
+
894
1787
  engine.pause(stallReason, sid);
1788
+
895
1789
  return;
1790
+
896
1791
  }
897
1792
 
1793
+
1794
+
898
1795
  const canContinue = engine.incrementIteration(sid);
1796
+
899
1797
  if (!canContinue) return;
900
1798
 
1799
+
1800
+
901
1801
  // Item 2: Low-Latency AutoDrive — setImmediate instead of static setTimeout
1802
+
902
1803
  const triggerNextTurn = () => {
1804
+
903
1805
  const currentSnap = engine.getSnapshot(sid);
1806
+
904
1807
  if (currentSnap.hasActiveGoal && currentSnap.state === GoalState.RUNNING) {
1808
+
905
1809
  const currentLang = currentSnap.lang || (currentSnap.title ? detectLanguage(currentSnap.title) : 'en');
1810
+
906
1811
  let promptText = currentLang === 'ru'
1812
+
907
1813
  ? 'Продолжай автономное выполнение цели согласно плану работ. Отмечай каждый выполненный шаг через goal_update_progress, а по завершении всех задач вызови goal_finish.'
1814
+
908
1815
  : 'Continue autonomous goal execution according to the work plan. Mark each completed step via goal_update_progress, and when all tasks are complete, call goal_finish.';
909
1816
 
1817
+
1818
+
910
1819
  if (currentSnap.pendingNudge) {
1820
+
911
1821
  const userNudge = engine.consumePendingNudge(sid);
1822
+
912
1823
  if (userNudge) {
1824
+
913
1825
  promptText = (currentLang === 'ru'
1826
+
914
1827
  ? `🚨 СРОЧНОЕ УТОЧНЕНИЕ / НАПРАВЛЕНИЕ ОТ ПОЛЬЗОВАТЕЛЯ:\n"${userNudge}"\nСкорректируй выполнение с учётом этого замечания.\n\n`
1828
+
915
1829
  : `🚨 URGENT USER CLARIFICATION / STEERING:\n"${userNudge}"\nAdjust execution adhering to this feedback.\n\n`) + promptText;
1830
+
916
1831
  }
1832
+
917
1833
  }
1834
+
918
1835
  const promptMsg = createGoalUserMessage(promptText);
1836
+
919
1837
  let target = sessionAgents.get(sid);
1838
+
920
1839
  if (!target && lastActiveAgent && typeof lastActiveAgent.followup === 'function') {
1840
+
921
1841
  const lastSid = sessionIdOf(lastActiveAgent, null);
1842
+
922
1843
  if (!lastSid || lastSid === sid || sid === 'default') {
1844
+
923
1845
  target = lastActiveAgent;
1846
+
924
1847
  }
1848
+
925
1849
  }
1850
+
926
1851
  if (!target && agentsService && typeof agentsService.list === 'function') {
1852
+
927
1853
  const list = agentsService.list();
1854
+
928
1855
  if (list && list.length > 0) {
1856
+
929
1857
  for (let i = list.length - 1; i >= 0; i--) {
1858
+
930
1859
  const cand = list[i];
1860
+
931
1861
  if (sessionIdOf(cand, null) === sid) {
1862
+
932
1863
  target = cand;
1864
+
933
1865
  break;
1866
+
934
1867
  }
1868
+
935
1869
  }
1870
+
936
1871
  if (!target && sid === 'default') target = list[list.length - 1];
1872
+
937
1873
  }
1874
+
938
1875
  }
939
1876
 
1877
+
1878
+
940
1879
  if (target && typeof target.followup === 'function' && target.status !== 'stopped') {
1880
+
941
1881
  try {
1882
+
942
1883
  target.followup(promptMsg);
1884
+
943
1885
  sessionAgents.set(sid, target);
1886
+
944
1887
  } catch (err) {
1888
+
945
1889
  console.warn('[dsh-goal] Failed auto-drive followup:', err);
1890
+
946
1891
  }
1892
+
947
1893
  }
1894
+
948
1895
  }
1896
+
949
1897
  };
950
1898
 
1899
+
1900
+
951
1901
  if (typeof setImmediate === 'function') {
1902
+
952
1903
  setImmediate(triggerNextTurn);
1904
+
953
1905
  } else {
1906
+
954
1907
  setTimeout(triggerNextTurn, 0);
1908
+
955
1909
  }
1910
+
956
1911
  }
1912
+
957
1913
  };
958
1914
 
1915
+
1916
+
959
1917
  // Подписка на запрос подтверждения (approval/asked) -> автоматическая пауза
1918
+
960
1919
  const onApprovalAsked = (event) => {
1920
+
961
1921
  const sid = sessionIdOf(event, 'default');
1922
+
962
1923
  const snap = engine.getSnapshot(sid);
1924
+
963
1925
  if (snap.hasActiveGoal && snap.state === GoalState.RUNNING) {
1926
+
964
1927
  const lang = snap.lang || (snap.title ? detectLanguage(snap.title) : 'en');
1928
+
965
1929
  const pauseReason = lang === 'ru'
1930
+
966
1931
  ? 'Ожидание подтверждения действия оператором'
1932
+
967
1933
  : 'Waiting for operator confirmation/approval';
1934
+
968
1935
  engine.pause(pauseReason, sid);
1936
+
969
1937
  }
1938
+
970
1939
  };
971
1940
 
1941
+
1942
+
972
1943
  ctx.on?.('turn/end', onTurnEnd);
1944
+
973
1945
  ctx.on?.('approval/asked', onApprovalAsked);
974
1946
 
1947
+
1948
+
975
1949
  return () => {
1950
+
976
1951
  ctx.off?.('turn/end', onTurnEnd);
1952
+
977
1953
  ctx.off?.('approval/asked', onApprovalAsked);
1954
+
978
1955
  };
1956
+
979
1957
  }, 'dsh-goal: Session & Turn Coordinator');
1958
+
980
1959
  }
1960
+