@dotsetlabs/dotclaw 1.2.0 → 1.4.0

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 (77) hide show
  1. package/README.md +9 -0
  2. package/config-examples/runtime.json +119 -2
  3. package/container/agent-runner/src/agent-config.ts +20 -2
  4. package/container/agent-runner/src/container-protocol.ts +11 -0
  5. package/container/agent-runner/src/index.ts +39 -7
  6. package/container/agent-runner/src/tools.ts +84 -5
  7. package/dist/agent-context.d.ts +5 -0
  8. package/dist/agent-context.d.ts.map +1 -1
  9. package/dist/agent-context.js +19 -8
  10. package/dist/agent-context.js.map +1 -1
  11. package/dist/agent-execution.d.ts +6 -0
  12. package/dist/agent-execution.d.ts.map +1 -1
  13. package/dist/agent-execution.js +61 -4
  14. package/dist/agent-execution.js.map +1 -1
  15. package/dist/background-job-classifier.d.ts +20 -0
  16. package/dist/background-job-classifier.d.ts.map +1 -0
  17. package/dist/background-job-classifier.js +145 -0
  18. package/dist/background-job-classifier.js.map +1 -0
  19. package/dist/background-jobs.d.ts.map +1 -1
  20. package/dist/background-jobs.js +81 -4
  21. package/dist/background-jobs.js.map +1 -1
  22. package/dist/cli.js +343 -11
  23. package/dist/cli.js.map +1 -1
  24. package/dist/config.d.ts +0 -2
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +0 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/container-protocol.d.ts +11 -0
  29. package/dist/container-protocol.d.ts.map +1 -1
  30. package/dist/container-runner.d.ts.map +1 -1
  31. package/dist/container-runner.js +9 -1
  32. package/dist/container-runner.js.map +1 -1
  33. package/dist/dashboard.d.ts +5 -0
  34. package/dist/dashboard.d.ts.map +1 -1
  35. package/dist/dashboard.js +58 -8
  36. package/dist/dashboard.js.map +1 -1
  37. package/dist/db.d.ts +16 -0
  38. package/dist/db.d.ts.map +1 -1
  39. package/dist/db.js +53 -0
  40. package/dist/db.js.map +1 -1
  41. package/dist/index.js +403 -30
  42. package/dist/index.js.map +1 -1
  43. package/dist/json-helpers.d.ts +6 -0
  44. package/dist/json-helpers.d.ts.map +1 -0
  45. package/dist/json-helpers.js +17 -0
  46. package/dist/json-helpers.js.map +1 -0
  47. package/dist/logger.d.ts +0 -1
  48. package/dist/logger.d.ts.map +1 -1
  49. package/dist/logger.js +1 -1
  50. package/dist/logger.js.map +1 -1
  51. package/dist/metrics.d.ts +3 -0
  52. package/dist/metrics.d.ts.map +1 -1
  53. package/dist/metrics.js +35 -1
  54. package/dist/metrics.js.map +1 -1
  55. package/dist/planner-probe.d.ts +14 -0
  56. package/dist/planner-probe.d.ts.map +1 -0
  57. package/dist/planner-probe.js +97 -0
  58. package/dist/planner-probe.js.map +1 -0
  59. package/dist/progress.d.ts +27 -0
  60. package/dist/progress.d.ts.map +1 -1
  61. package/dist/progress.js +151 -0
  62. package/dist/progress.js.map +1 -1
  63. package/dist/request-router.d.ts +34 -0
  64. package/dist/request-router.d.ts.map +1 -0
  65. package/dist/request-router.js +148 -0
  66. package/dist/request-router.js.map +1 -0
  67. package/dist/runtime-config.d.ts +81 -0
  68. package/dist/runtime-config.d.ts.map +1 -1
  69. package/dist/runtime-config.js +190 -13
  70. package/dist/runtime-config.js.map +1 -1
  71. package/dist/task-scheduler.d.ts.map +1 -1
  72. package/dist/task-scheduler.js +56 -9
  73. package/dist/task-scheduler.js.map +1 -1
  74. package/dist/trace-writer.d.ts +1 -0
  75. package/dist/trace-writer.d.ts.map +1 -1
  76. package/dist/trace-writer.js.map +1 -1
  77. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import path from 'path';
6
6
  import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, IPC_POLL_INTERVAL, TIMEZONE, CONTAINER_MODE, WARM_START_ENABLED, ENV_PATH } from './config.js';
7
7
  // Load .env from the canonical location (~/.dotclaw/.env)
8
8
  dotenv.config({ path: ENV_PATH });
9
- import { initDatabase, storeMessage, getMessagesSinceCursor, getChatState, updateChatState, createTask, updateTask, deleteTask, getTaskById, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, linkMessageToTrace, getTraceIdForMessage, recordUserFeedback } from './db.js';
9
+ import { initDatabase, storeMessage, upsertChat, getMessagesSinceCursor, getChatState, updateChatState, createTask, updateTask, deleteTask, getTaskById, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, getBackgroundJobQueuePosition, getBackgroundJobQueueDepth, linkMessageToTrace, getTraceIdForMessage, recordUserFeedback } from './db.js';
10
10
  import { startSchedulerLoop, runTaskNow } from './task-scheduler.js';
11
11
  import { startBackgroundJobLoop, spawnBackgroundJob, getBackgroundJobStatus, listBackgroundJobsForGroup, cancelBackgroundJob, recordBackgroundJobUpdate } from './background-jobs.js';
12
12
  import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
@@ -14,10 +14,10 @@ import { writeTrace } from './trace-writer.js';
14
14
  import { formatTelegramMessage, TELEGRAM_PARSE_MODE } from './telegram-format.js';
15
15
  import { initMemoryStore, getMemoryStats, upsertMemoryItems, searchMemories, listMemories, forgetMemories, cleanupExpiredMemories } from './memory-store.js';
16
16
  import { startEmbeddingWorker } from './memory-embeddings.js';
17
- import { createProgressNotifier, DEFAULT_PROGRESS_MESSAGES } from './progress.js';
17
+ import { createProgressManager, DEFAULT_PROGRESS_MESSAGES, DEFAULT_PROGRESS_STAGES, formatProgressWithPlan, formatPlanStepList } from './progress.js';
18
18
  import { parseAdminCommand } from './admin-commands.js';
19
19
  import { loadModelRegistry, saveModelRegistry } from './model-registry.js';
20
- import { startMetricsServer, recordMessage, recordError } from './metrics.js';
20
+ import { startMetricsServer, recordMessage, recordError, recordRoutingDecision, recordStageLatency } from './metrics.js';
21
21
  import { startMaintenanceLoop } from './maintenance.js';
22
22
  import { warmGroupContainer, startDaemonHealthCheckLoop } from './container-runner.js';
23
23
  import { loadRuntimeConfig } from './runtime-config.js';
@@ -25,6 +25,9 @@ import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionE
25
25
  import { logger } from './logger.js';
26
26
  import { startDashboard, setTelegramConnected, setLastMessageTime, setMessageQueueDepth } from './dashboard.js';
27
27
  import { humanizeError } from './error-messages.js';
28
+ import { classifyBackgroundJob } from './background-job-classifier.js';
29
+ import { routeRequest, routePrompt } from './request-router.js';
30
+ import { probePlanner } from './planner-probe.js';
28
31
  const runtime = loadRuntimeConfig();
29
32
  function buildTriggerRegex(pattern) {
30
33
  if (!pattern)
@@ -145,16 +148,17 @@ const TELEGRAM_STREAM_MIN_CHARS = runtime.host.telegram.streamMinChars;
145
148
  const MEMORY_RECALL_MAX_RESULTS = runtime.host.memory.recall.maxResults;
146
149
  const MEMORY_RECALL_MAX_TOKENS = runtime.host.memory.recall.maxTokens;
147
150
  const INPUT_MESSAGE_MAX_CHARS = runtime.host.telegram.inputMessageMaxChars;
148
- const PROGRESS_ENABLED = runtime.host.progress.enabled;
149
- const PROGRESS_INITIAL_MS = runtime.host.progress.initialMs;
150
- const PROGRESS_INTERVAL_MS = runtime.host.progress.intervalMs;
151
- const PROGRESS_MAX_UPDATES = runtime.host.progress.maxUpdates;
152
- const PROGRESS_MESSAGES = runtime.host.progress.messages.length > 0
153
- ? runtime.host.progress.messages
154
- : DEFAULT_PROGRESS_MESSAGES;
155
151
  const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
156
152
  const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
157
153
  const HEARTBEAT_GROUP_FOLDER = (runtime.host.heartbeat.groupFolder || MAIN_GROUP_FOLDER).trim() || MAIN_GROUP_FOLDER;
154
+ const BACKGROUND_JOBS_ENABLED = runtime.host.backgroundJobs.enabled;
155
+ const AUTO_SPAWN_CONFIG = runtime.host.backgroundJobs.autoSpawn;
156
+ const AUTO_SPAWN_ENABLED = BACKGROUND_JOBS_ENABLED && AUTO_SPAWN_CONFIG.enabled;
157
+ const AUTO_SPAWN_FOREGROUND_TIMEOUT_MS = AUTO_SPAWN_CONFIG.foregroundTimeoutMs;
158
+ const AUTO_SPAWN_ON_TIMEOUT = AUTO_SPAWN_CONFIG.onTimeout;
159
+ const AUTO_SPAWN_ON_TOOL_LIMIT = AUTO_SPAWN_CONFIG.onToolLimit;
160
+ const AUTO_SPAWN_CLASSIFIER_ENABLED = AUTO_SPAWN_CONFIG.classifier.enabled;
161
+ const TOOL_CALL_FALLBACK_PATTERN = /tool calls? but did not get a final response/i;
158
162
  // Initialize Telegram bot with extended timeout for long-running agent tasks
159
163
  const telegrafBot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN, {
160
164
  handlerTimeout: TELEGRAM_HANDLER_TIMEOUT_MS
@@ -167,6 +171,64 @@ let registeredGroups = {};
167
171
  const TELEGRAM_MAX_MESSAGE_LENGTH = 4000;
168
172
  const TELEGRAM_SEND_DELAY_MS = 250;
169
173
  const messageQueues = new Map();
174
+ const activeRuns = new Map();
175
+ function isCancelMessage(content) {
176
+ if (!content)
177
+ return false;
178
+ const normalized = content.trim().toLowerCase();
179
+ return normalized === 'cancel'
180
+ || normalized === 'stop'
181
+ || normalized === 'abort'
182
+ || normalized === 'cancel request'
183
+ || normalized === 'stop request';
184
+ }
185
+ function inferProgressStage(params) {
186
+ const content = params.content.toLowerCase();
187
+ const tools = params.plannerTools.map(tool => tool.toLowerCase());
188
+ const hasWebTool = tools.some(tool => tool.includes('web') || tool.includes('search') || tool.includes('fetch'));
189
+ const hasCodeTool = tools.some(tool => tool.includes('bash') || tool.includes('edit') || tool.includes('write') || tool.includes('git'));
190
+ if (params.enablePlanner)
191
+ return 'planning';
192
+ if (hasWebTool || /research|search|browse|web|site|docs/.test(content))
193
+ return 'searching';
194
+ if (hasCodeTool || /build|code|implement|refactor|fix|debug/.test(content))
195
+ return 'coding';
196
+ return 'drafting';
197
+ }
198
+ function estimateForegroundMs(params) {
199
+ if (typeof params.routing.estimatedMinutes === 'number' && Number.isFinite(params.routing.estimatedMinutes)) {
200
+ return Math.max(1000, params.routing.estimatedMinutes * 60_000);
201
+ }
202
+ const baseChars = params.content.length;
203
+ if (baseChars === 0)
204
+ return null;
205
+ const stepFactor = params.plannerSteps.length > 0 ? params.plannerSteps.length * 6000 : 0;
206
+ const toolFactor = params.plannerTools.length > 0 ? params.plannerTools.length * 8000 : 0;
207
+ const lengthFactor = Math.min(60_000, Math.max(3000, Math.round(baseChars / 3)));
208
+ const profileFactor = params.routing.profile === 'deep' ? 1.4 : 1;
209
+ return Math.round((lengthFactor + stepFactor + toolFactor) * profileFactor);
210
+ }
211
+ function inferPlanStepIndex(stage, totalSteps) {
212
+ if (!Number.isFinite(totalSteps) || totalSteps <= 0)
213
+ return null;
214
+ const normalized = stage.trim().toLowerCase();
215
+ if (!normalized)
216
+ return 1;
217
+ switch (normalized) {
218
+ case 'planning':
219
+ return 1;
220
+ case 'searching':
221
+ return Math.min(2, totalSteps);
222
+ case 'coding':
223
+ return Math.min(Math.max(2, Math.ceil(totalSteps * 0.6)), totalSteps);
224
+ case 'drafting':
225
+ return Math.min(Math.max(2, Math.ceil(totalSteps * 0.8)), totalSteps);
226
+ case 'finalizing':
227
+ return totalSteps;
228
+ default:
229
+ return 1;
230
+ }
231
+ }
170
232
  const draftSessions = new Map();
171
233
  function parseTelegramStreamMode(value) {
172
234
  const normalized = value.trim().toLowerCase();
@@ -557,6 +619,17 @@ async function finalizeStreamedMessage(msg, draftId, text) {
557
619
  clearDraftSession(msg.chatId, draftId);
558
620
  }
559
621
  function enqueueMessage(msg) {
622
+ if (isCancelMessage(msg.content)) {
623
+ const controller = activeRuns.get(msg.chatId);
624
+ if (controller) {
625
+ controller.abort();
626
+ activeRuns.delete(msg.chatId);
627
+ void sendMessage(msg.chatId, 'Canceled the current request.', { messageThreadId: msg.messageThreadId });
628
+ return;
629
+ }
630
+ void sendMessage(msg.chatId, 'There is no active request to cancel.', { messageThreadId: msg.messageThreadId });
631
+ return;
632
+ }
560
633
  const existing = messageQueues.get(msg.chatId);
561
634
  if (existing) {
562
635
  existing.pendingMessage = msg;
@@ -632,6 +705,20 @@ async function processMessage(msg) {
632
705
  ${lines.join('\n')}
633
706
  </messages>`;
634
707
  const lastMessage = missedMessages[missedMessages.length - 1];
708
+ const routingStartedAt = Date.now();
709
+ const routingDecision = routeRequest({
710
+ prompt,
711
+ lastMessage
712
+ });
713
+ recordRoutingDecision(routingDecision.profile);
714
+ const routerMs = Date.now() - routingStartedAt;
715
+ recordStageLatency('router', routerMs, 'telegram');
716
+ logger.info({
717
+ chatId: msg.chatId,
718
+ profile: routingDecision.profile,
719
+ reason: routingDecision.reason,
720
+ shouldBackground: routingDecision.shouldBackground
721
+ }, 'Routing decision');
635
722
  const traceBase = createTraceBase({
636
723
  chatId: msg.chatId,
637
724
  groupFolder: group.folder,
@@ -648,17 +735,224 @@ ${lines.join('\n')}
648
735
  let output = null;
649
736
  let context = null;
650
737
  let errorMessage = null;
651
- const progressNotifier = createProgressNotifier({
652
- enabled: PROGRESS_ENABLED && !streamingEnabled,
653
- initialDelayMs: PROGRESS_INITIAL_MS,
654
- intervalMs: PROGRESS_INTERVAL_MS,
655
- maxUpdates: PROGRESS_MAX_UPDATES,
656
- messages: PROGRESS_MESSAGES,
738
+ const isTimeoutError = (value) => {
739
+ if (!value)
740
+ return false;
741
+ return /timed out|timeout/i.test(value);
742
+ };
743
+ const shouldPlannerProbe = () => {
744
+ const config = runtime.host.routing.plannerProbe;
745
+ if (!config.enabled)
746
+ return false;
747
+ if (routingDecision.profile === 'fast' || routingDecision.shouldBackground)
748
+ return false;
749
+ const contentLength = lastMessage?.content?.length || 0;
750
+ return contentLength >= config.minChars;
751
+ };
752
+ const maybeAutoSpawn = async (reason, detail, overrides) => {
753
+ if (!BACKGROUND_JOBS_ENABLED)
754
+ return false;
755
+ if (reason !== 'router' && !AUTO_SPAWN_ENABLED)
756
+ return false;
757
+ if (reason === 'timeout' && !AUTO_SPAWN_ON_TIMEOUT)
758
+ return false;
759
+ if (reason === 'tool_limit' && !AUTO_SPAWN_ON_TOOL_LIMIT)
760
+ return false;
761
+ const tags = ['auto-spawn', reason, `profile:${routingDecision.profile}`];
762
+ if (overrides?.tags && overrides.tags.length > 0) {
763
+ tags.push(...overrides.tags);
764
+ }
765
+ if (routingDecision.estimatedMinutes) {
766
+ tags.push(`eta:${routingDecision.estimatedMinutes}`);
767
+ }
768
+ const estimatedMs = typeof routingDecision.estimatedMinutes === 'number'
769
+ ? routingDecision.estimatedMinutes * 60_000
770
+ : null;
771
+ const computedTimeoutMs = estimatedMs
772
+ ? Math.min(runtime.host.backgroundJobs.maxRuntimeMs, Math.max(5 * 60_000, Math.round(estimatedMs * 2)))
773
+ : undefined;
774
+ const result = spawnBackgroundJob({
775
+ prompt,
776
+ groupFolder: group.folder,
777
+ chatJid: msg.chatId,
778
+ contextMode: 'group',
779
+ tags,
780
+ parentTraceId: traceBase.trace_id,
781
+ parentMessageId: msg.messageId,
782
+ modelOverride: overrides?.modelOverride ?? routingDecision.modelOverride,
783
+ maxToolSteps: overrides?.maxToolSteps ?? routingDecision.maxToolSteps,
784
+ toolAllow: routingDecision.toolAllow,
785
+ toolDeny: routingDecision.toolDeny,
786
+ timeoutMs: overrides?.timeoutMs ?? computedTimeoutMs
787
+ });
788
+ if (!result.ok || !result.jobId) {
789
+ logger.warn({ chatId: msg.chatId, reason, error: result.error }, 'Auto-spawn background job failed');
790
+ return false;
791
+ }
792
+ const queuePosition = getBackgroundJobQueuePosition({ jobId: result.jobId, groupFolder: group.folder });
793
+ const eta = routingDecision.estimatedMinutes ? `${routingDecision.estimatedMinutes} min` : null;
794
+ const detailLine = detail ? `\n\nReason: ${detail}` : '';
795
+ const queueLine = queuePosition ? `\n\nQueue position: ${queuePosition.position} of ${queuePosition.total}` : '';
796
+ const etaLine = eta ? `\n\nEstimated time: ${eta}` : '';
797
+ const planPreview = plannerProbeSteps.length > 0
798
+ ? formatPlanStepList({ steps: plannerProbeSteps, currentStep: 1, maxSteps: 4 })
799
+ : '';
800
+ const planLine = planPreview ? `\n\nPlanned steps:\n${planPreview}` : '';
801
+ await sendMessage(msg.chatId, `Queued this as background job ${result.jobId}. I'll report back when it's done. You can keep chatting while it runs.${queueLine}${etaLine}${detailLine}${planLine}`, { messageThreadId: msg.messageThreadId });
802
+ if (lastMessage) {
803
+ updateChatState(msg.chatId, lastMessage.timestamp, lastMessage.id);
804
+ }
805
+ if (draftId) {
806
+ clearDraftSession(msg.chatId, draftId);
807
+ }
808
+ return true;
809
+ };
810
+ let plannerProbeTools = [];
811
+ let plannerProbeSteps = [];
812
+ let plannerProbeMs = null;
813
+ if (shouldPlannerProbe() && lastMessage) {
814
+ const probeStarted = Date.now();
815
+ const probeResult = await probePlanner({
816
+ lastMessage,
817
+ recentMessages: missedMessages
818
+ });
819
+ plannerProbeMs = Date.now() - probeStarted;
820
+ recordStageLatency('planner_probe', plannerProbeMs, 'telegram');
821
+ if (probeResult.steps.length > 0)
822
+ plannerProbeSteps = probeResult.steps;
823
+ if (probeResult.tools.length > 0)
824
+ plannerProbeTools = probeResult.tools;
825
+ logger.info({
826
+ chatId: msg.chatId,
827
+ shouldBackground: probeResult.shouldBackground,
828
+ steps: probeResult.steps.length,
829
+ tools: probeResult.tools.length,
830
+ latencyMs: probeResult.latencyMs,
831
+ model: probeResult.model,
832
+ error: probeResult.error
833
+ }, 'Planner probe decision');
834
+ if (probeResult.shouldBackground) {
835
+ const autoSpawned = await maybeAutoSpawn('planner', 'planner probe predicted multi-step work');
836
+ if (autoSpawned)
837
+ return true;
838
+ }
839
+ }
840
+ if (routingDecision.shouldBackground) {
841
+ const autoSpawned = await maybeAutoSpawn('router', routingDecision.reason);
842
+ if (autoSpawned) {
843
+ return true;
844
+ }
845
+ }
846
+ let classifierMs = null;
847
+ if (AUTO_SPAWN_ENABLED && AUTO_SPAWN_CLASSIFIER_ENABLED && lastMessage && routingDecision.shouldRunClassifier) {
848
+ try {
849
+ const queueDepth = getBackgroundJobQueueDepth({ groupFolder: group.folder });
850
+ const classifierResult = await classifyBackgroundJob({
851
+ lastMessage,
852
+ recentMessages: missedMessages,
853
+ isGroup: msg.isGroup,
854
+ chatType: msg.chatType,
855
+ queueDepth,
856
+ metricsSource: 'telegram'
857
+ });
858
+ if (classifierResult.latencyMs) {
859
+ classifierMs = classifierResult.latencyMs;
860
+ recordStageLatency('classifier', classifierResult.latencyMs, 'telegram');
861
+ }
862
+ logger.info({
863
+ chatId: msg.chatId,
864
+ decision: classifierResult.shouldBackground,
865
+ confidence: classifierResult.confidence,
866
+ latencyMs: classifierResult.latencyMs,
867
+ model: classifierResult.model,
868
+ reason: classifierResult.reason,
869
+ error: classifierResult.error
870
+ }, 'Background job classifier decision');
871
+ if (classifierResult.shouldBackground) {
872
+ const estimated = classifierResult.estimatedMinutes;
873
+ if (typeof estimated === 'number' && Number.isFinite(estimated) && estimated > 0) {
874
+ routingDecision.estimatedMinutes = Math.round(estimated);
875
+ }
876
+ const autoSpawned = await maybeAutoSpawn('classifier', classifierResult.reason);
877
+ if (autoSpawned) {
878
+ return true;
879
+ }
880
+ }
881
+ }
882
+ catch (err) {
883
+ logger.warn({ chatId: msg.chatId, err }, 'Background job classifier failed');
884
+ }
885
+ }
886
+ const predictedStage = inferProgressStage({
887
+ content: lastMessage?.content || prompt,
888
+ plannerTools: plannerProbeTools,
889
+ plannerSteps: plannerProbeSteps,
890
+ enablePlanner: routingDecision.enablePlanner
891
+ });
892
+ const predictedMs = estimateForegroundMs({
893
+ content: lastMessage?.content || prompt,
894
+ routing: routingDecision,
895
+ plannerSteps: plannerProbeSteps,
896
+ plannerTools: plannerProbeTools
897
+ });
898
+ const planStepIndex = inferPlanStepIndex(predictedStage, plannerProbeSteps.length);
899
+ const progressManager = createProgressManager({
900
+ enabled: routingDecision.progress.enabled && !streamingEnabled,
901
+ initialDelayMs: routingDecision.progress.initialMs,
902
+ intervalMs: routingDecision.progress.intervalMs,
903
+ maxUpdates: routingDecision.progress.maxUpdates,
904
+ messages: routingDecision.progress.messages.length > 0
905
+ ? routingDecision.progress.messages
906
+ : DEFAULT_PROGRESS_MESSAGES,
907
+ stageMessages: DEFAULT_PROGRESS_STAGES,
908
+ stageThrottleMs: 20_000,
657
909
  send: async (text) => { await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId }); },
658
910
  onError: (err) => logger.debug({ chatId: msg.chatId, err }, 'Failed to send progress update')
659
911
  });
660
- progressNotifier.start();
912
+ progressManager.start();
913
+ let sentPlan = false;
914
+ if (predictedMs && predictedMs >= 10_000 && routingDecision.progress.enabled && !streamingEnabled) {
915
+ if (plannerProbeSteps.length > 0) {
916
+ const planMessage = formatProgressWithPlan({
917
+ steps: plannerProbeSteps,
918
+ currentStep: planStepIndex ?? 1,
919
+ stage: predictedStage
920
+ });
921
+ progressManager.notify(planMessage);
922
+ sentPlan = true;
923
+ }
924
+ else {
925
+ progressManager.notify(DEFAULT_PROGRESS_STAGES.ack);
926
+ }
927
+ }
928
+ if (!(sentPlan && predictedStage === 'planning')) {
929
+ progressManager.setStage(predictedStage);
930
+ }
931
+ if (predictedStage === 'planning') {
932
+ const followUpStage = inferProgressStage({
933
+ content: lastMessage?.content || prompt,
934
+ plannerTools: plannerProbeTools,
935
+ plannerSteps: plannerProbeSteps,
936
+ enablePlanner: false
937
+ });
938
+ if (followUpStage !== 'planning') {
939
+ const delay = Math.min(15_000, Math.max(5_000, Math.floor(routingDecision.progress.initialMs / 2)));
940
+ setTimeout(() => progressManager.setStage(followUpStage), delay);
941
+ }
942
+ }
943
+ const abortController = new AbortController();
944
+ activeRuns.set(msg.chatId, abortController);
661
945
  try {
946
+ const recallMaxResults = routingDecision.enableMemoryRecall
947
+ ? (Number.isFinite(routingDecision.recallMaxResults)
948
+ ? Math.max(0, Math.floor(routingDecision.recallMaxResults))
949
+ : MEMORY_RECALL_MAX_RESULTS)
950
+ : 0;
951
+ const recallMaxTokens = routingDecision.enableMemoryRecall
952
+ ? (Number.isFinite(routingDecision.recallMaxTokens)
953
+ ? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
954
+ : MEMORY_RECALL_MAX_TOKENS)
955
+ : 0;
662
956
  const execution = await executeAgentRun({
663
957
  group,
664
958
  prompt,
@@ -666,8 +960,10 @@ ${lines.join('\n')}
666
960
  userId: msg.senderId,
667
961
  userName: msg.senderName,
668
962
  recallQuery: recallQuery || msg.content,
669
- recallMaxResults: MEMORY_RECALL_MAX_RESULTS,
670
- recallMaxTokens: MEMORY_RECALL_MAX_TOKENS,
963
+ recallMaxResults,
964
+ recallMaxTokens,
965
+ toolAllow: routingDecision.toolAllow,
966
+ toolDeny: routingDecision.toolDeny,
671
967
  sessionId: sessions[group.folder],
672
968
  onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
673
969
  streaming: streamingEnabled && draftId
@@ -678,10 +974,22 @@ ${lines.join('\n')}
678
974
  minChars: TELEGRAM_STREAM_MIN_CHARS
679
975
  }
680
976
  : undefined,
681
- availableGroups: buildAvailableGroupsSnapshot()
977
+ availableGroups: buildAvailableGroupsSnapshot(),
978
+ modelOverride: routingDecision.modelOverride,
979
+ modelMaxOutputTokens: routingDecision.maxOutputTokens,
980
+ maxToolSteps: routingDecision.maxToolSteps,
981
+ disablePlanner: !routingDecision.enablePlanner,
982
+ disableResponseValidation: !routingDecision.enableResponseValidation,
983
+ responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
984
+ disableMemoryExtraction: !routingDecision.enableMemoryExtraction,
985
+ abortSignal: abortController.signal,
986
+ timeoutMs: AUTO_SPAWN_ENABLED && AUTO_SPAWN_FOREGROUND_TIMEOUT_MS > 0
987
+ ? AUTO_SPAWN_FOREGROUND_TIMEOUT_MS
988
+ : undefined
682
989
  });
683
990
  output = execution.output;
684
991
  context = execution.context;
992
+ progressManager.setStage('finalizing');
685
993
  if (output.status === 'error') {
686
994
  errorMessage = output.error || 'Unknown error';
687
995
  }
@@ -697,8 +1005,15 @@ ${lines.join('\n')}
697
1005
  logger.error({ group: group.name, err }, 'Agent error');
698
1006
  }
699
1007
  finally {
700
- progressNotifier.stop();
701
- }
1008
+ progressManager.stop();
1009
+ activeRuns.delete(msg.chatId);
1010
+ }
1011
+ const extraTimings = {};
1012
+ extraTimings.router_ms = routerMs;
1013
+ if (classifierMs !== null)
1014
+ extraTimings.classifier_ms = classifierMs;
1015
+ if (plannerProbeMs !== null)
1016
+ extraTimings.planner_probe_ms = plannerProbeMs;
702
1017
  if (!output) {
703
1018
  const message = errorMessage || 'No output from agent';
704
1019
  if (context) {
@@ -709,7 +1024,8 @@ ${lines.join('\n')}
709
1024
  metricsSource: 'telegram',
710
1025
  toolAuditSource: 'message',
711
1026
  errorMessage: message,
712
- errorType: 'agent'
1027
+ errorType: 'agent',
1028
+ extraTimings
713
1029
  });
714
1030
  }
715
1031
  else {
@@ -729,6 +1045,12 @@ ${lines.join('\n')}
729
1045
  source: traceBase.source
730
1046
  });
731
1047
  }
1048
+ if (isTimeoutError(message)) {
1049
+ const autoSpawned = await maybeAutoSpawn('timeout', message);
1050
+ if (autoSpawned) {
1051
+ return true;
1052
+ }
1053
+ }
732
1054
  const userMessage = humanizeError(errorMessage || 'Unknown error');
733
1055
  await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
734
1056
  if (draftId) {
@@ -745,11 +1067,19 @@ ${lines.join('\n')}
745
1067
  metricsSource: 'telegram',
746
1068
  toolAuditSource: 'message',
747
1069
  errorMessage: errorMessage || output.error || 'Unknown error',
748
- errorType: 'agent'
1070
+ errorType: 'agent',
1071
+ extraTimings
749
1072
  });
750
1073
  }
751
1074
  logger.error({ group: group.name, error: output.error }, 'Container agent error');
752
- const userMessage = humanizeError(errorMessage || output.error || 'Unknown error');
1075
+ const errorText = errorMessage || output.error || 'Unknown error';
1076
+ if (isTimeoutError(errorText)) {
1077
+ const autoSpawned = await maybeAutoSpawn('timeout', errorText);
1078
+ if (autoSpawned) {
1079
+ return true;
1080
+ }
1081
+ }
1082
+ const userMessage = humanizeError(errorText);
753
1083
  await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
754
1084
  if (draftId) {
755
1085
  clearDraftSession(msg.chatId, draftId);
@@ -780,6 +1110,23 @@ ${lines.join('\n')}
780
1110
  }
781
1111
  }
782
1112
  else if (output.tool_calls && output.tool_calls.length > 0) {
1113
+ const toolLimitHit = !output.result || !output.result.trim() || TOOL_CALL_FALLBACK_PATTERN.test(output.result);
1114
+ if (toolLimitHit) {
1115
+ const autoSpawned = await maybeAutoSpawn('tool_limit', 'Tool-call step limit reached');
1116
+ if (autoSpawned) {
1117
+ if (context) {
1118
+ recordAgentTelemetry({
1119
+ traceBase,
1120
+ output,
1121
+ context,
1122
+ metricsSource: 'telegram',
1123
+ toolAuditSource: 'message',
1124
+ extraTimings
1125
+ });
1126
+ }
1127
+ return true;
1128
+ }
1129
+ }
783
1130
  await sendMessage(msg.chatId, 'I hit my tool-call step limit before I could finish. If you want me to keep going, please narrow the scope or ask for a specific subtask.', { messageThreadId: msg.messageThreadId });
784
1131
  if (draftId) {
785
1132
  clearDraftSession(msg.chatId, draftId);
@@ -797,7 +1144,8 @@ ${lines.join('\n')}
797
1144
  output,
798
1145
  context,
799
1146
  metricsSource: 'telegram',
800
- toolAuditSource: 'message'
1147
+ toolAuditSource: 'message',
1148
+ extraTimings
801
1149
  });
802
1150
  }
803
1151
  return true;
@@ -997,11 +1345,22 @@ async function runHeartbeatOnce() {
997
1345
  inputText: prompt,
998
1346
  source: 'dotclaw-heartbeat'
999
1347
  });
1348
+ const routingStartedAt = Date.now();
1349
+ const routingDecision = routePrompt(prompt);
1350
+ recordRoutingDecision(routingDecision.profile);
1351
+ const routerMs = Date.now() - routingStartedAt;
1352
+ recordStageLatency('router', routerMs, 'scheduler');
1000
1353
  let output = null;
1001
1354
  let context = null;
1002
1355
  let errorMessage = null;
1003
- const recallMaxResults = Math.max(4, MEMORY_RECALL_MAX_RESULTS - 2);
1004
- const recallMaxTokens = Math.max(600, MEMORY_RECALL_MAX_TOKENS - 200);
1356
+ const baseRecallResults = Number.isFinite(routingDecision.recallMaxResults)
1357
+ ? Math.max(0, Math.floor(routingDecision.recallMaxResults))
1358
+ : MEMORY_RECALL_MAX_RESULTS;
1359
+ const baseRecallTokens = Number.isFinite(routingDecision.recallMaxTokens)
1360
+ ? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
1361
+ : MEMORY_RECALL_MAX_TOKENS;
1362
+ const recallMaxResults = routingDecision.enableMemoryRecall ? Math.max(4, baseRecallResults - 2) : 0;
1363
+ const recallMaxTokens = routingDecision.enableMemoryRecall ? Math.max(600, baseRecallTokens - 200) : 0;
1005
1364
  try {
1006
1365
  const execution = await executeAgentRun({
1007
1366
  group,
@@ -1014,7 +1373,14 @@ async function runHeartbeatOnce() {
1014
1373
  sessionId: sessions[group.folder],
1015
1374
  onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
1016
1375
  isScheduledTask: true,
1017
- availableGroups: buildAvailableGroupsSnapshot()
1376
+ availableGroups: buildAvailableGroupsSnapshot(),
1377
+ modelOverride: routingDecision.modelOverride,
1378
+ modelMaxOutputTokens: routingDecision.maxOutputTokens,
1379
+ maxToolSteps: routingDecision.maxToolSteps,
1380
+ disablePlanner: !routingDecision.enablePlanner,
1381
+ disableResponseValidation: !routingDecision.enableResponseValidation,
1382
+ responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
1383
+ disableMemoryExtraction: !routingDecision.enableMemoryExtraction
1018
1384
  });
1019
1385
  output = execution.output;
1020
1386
  context = execution.context;
@@ -1038,7 +1404,8 @@ async function runHeartbeatOnce() {
1038
1404
  output,
1039
1405
  context,
1040
1406
  toolAuditSource: 'heartbeat',
1041
- errorMessage: errorMessage ?? undefined
1407
+ errorMessage: errorMessage ?? undefined,
1408
+ extraTimings: { router_ms: routerMs }
1042
1409
  });
1043
1410
  }
1044
1411
  else if (errorMessage) {
@@ -1817,6 +2184,11 @@ function setupTelegramHandlers() {
1817
2184
  const isPrivate = chatType === 'private';
1818
2185
  const senderId = String(ctx.from?.id || ctx.chat.id);
1819
2186
  const senderName = ctx.from?.first_name || ctx.from?.username || 'User';
2187
+ const chatName = ('title' in ctx.chat && ctx.chat.title)
2188
+ || ('username' in ctx.chat && ctx.chat.username)
2189
+ || ctx.from?.first_name
2190
+ || ctx.from?.username
2191
+ || senderName;
1820
2192
  const content = ctx.message.text;
1821
2193
  const timestamp = new Date(ctx.message.date * 1000).toISOString();
1822
2194
  const messageId = String(ctx.message.message_id);
@@ -1825,6 +2197,7 @@ function setupTelegramHandlers() {
1825
2197
  logger.info({ chatId, isGroup, senderName }, `Telegram message: ${content.substring(0, 50)}...`);
1826
2198
  try {
1827
2199
  // Store message in database
2200
+ upsertChat({ chatId, name: chatName, lastMessageTime: timestamp });
1828
2201
  storeMessage(String(ctx.message.message_id), chatId, senderId, senderName, content, timestamp, false);
1829
2202
  }
1830
2203
  catch (error) {