@gajae-code/coding-agent 0.11.8 → 0.11.10

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 (101) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  6. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  7. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  8. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  9. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  11. package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
  12. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  13. package/dist/types/modes/components/welcome.d.ts +2 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  15. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  16. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  17. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  18. package/dist/types/sdk/bus/index.d.ts +8 -1
  19. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  20. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  21. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  22. package/dist/types/sdk/session.d.ts +7 -0
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
  25. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  26. package/dist/types/session/session-manager.d.ts +5 -1
  27. package/dist/types/session/session-storage.d.ts +13 -0
  28. package/dist/types/skill-state/active-state.d.ts +4 -0
  29. package/dist/types/task/provider-retry-status.d.ts +13 -0
  30. package/dist/types/task/types.d.ts +3 -0
  31. package/dist/types/tools/index.d.ts +3 -1
  32. package/package.json +8 -8
  33. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  34. package/src/cli/mcp-cli.ts +4 -21
  35. package/src/cli/notify-cli.ts +1 -0
  36. package/src/cli.ts +32 -0
  37. package/src/commands/deep-interview.ts +9 -4
  38. package/src/commit/agentic/index.ts +3 -3
  39. package/src/commit/map-reduce/index.ts +2 -2
  40. package/src/config/model-profiles.ts +9 -10
  41. package/src/config/model-registry.ts +4 -2
  42. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  43. package/src/eval/js/tool-bridge.ts +1 -1
  44. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  45. package/src/gjc-runtime/deep-interview-draft.ts +843 -0
  46. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  47. package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
  48. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  50. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  51. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  52. package/src/gjc-runtime/state-writer.ts +166 -2
  53. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  54. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  55. package/src/hooks/skill-state.ts +23 -1
  56. package/src/internal-urls/docs-index.generated.ts +6 -5
  57. package/src/modes/components/welcome.ts +10 -2
  58. package/src/modes/controllers/selector-controller.ts +38 -8
  59. package/src/modes/interactive-mode.ts +29 -9
  60. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  61. package/src/prompts/agents/architect.md +8 -1
  62. package/src/prompts/agents/critic.md +8 -1
  63. package/src/prompts/agents/planner.md +8 -1
  64. package/src/prompts/system/system-prompt.md +1 -0
  65. package/src/runtime-mcp/json-rpc.ts +7 -4
  66. package/src/runtime-mcp/redaction.ts +30 -0
  67. package/src/sdk/broker/identity.ts +22 -12
  68. package/src/sdk/broker/session-index.ts +11 -1
  69. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  70. package/src/sdk/bus/daemon-paths.ts +2 -0
  71. package/src/sdk/bus/index.ts +60 -5
  72. package/src/sdk/bus/notification-service.ts +19 -5
  73. package/src/sdk/bus/operator-runtime.ts +31 -5
  74. package/src/sdk/bus/telegram-cli.ts +6 -13
  75. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  76. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  77. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  78. package/src/sdk/bus/telegram-daemon.ts +685 -120
  79. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  80. package/src/sdk/session.ts +12 -0
  81. package/src/session/agent-session.ts +140 -10
  82. package/src/session/internal/managed-session-scope.ts +339 -228
  83. package/src/session/internal/managed-session-storage.ts +17 -4
  84. package/src/session/session-manager.ts +96 -33
  85. package/src/session/session-storage.ts +23 -0
  86. package/src/skill-state/active-state.ts +10 -0
  87. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  88. package/src/task/executor.ts +154 -44
  89. package/src/task/provider-retry-status.ts +88 -0
  90. package/src/task/render.ts +45 -26
  91. package/src/task/types.ts +3 -0
  92. package/src/tools/ask.ts +1 -0
  93. package/src/tools/bash-allowed-prefixes.ts +45 -1
  94. package/src/tools/index.ts +3 -1
  95. package/src/tools/subagent-render.ts +74 -16
  96. package/src/tools/subagent.ts +2 -0
  97. package/vendor/insane-search/MANIFEST.json +4 -1
  98. package/vendor/insane-search/engine/bias_check.py +5 -0
  99. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  100. package/vendor/insane-search/engine/transport.py +13 -2
  101. package/vendor/insane-search/engine/validators.py +4 -1
@@ -15,7 +15,7 @@ import type {
15
15
  } from "@gajae-code/agent-core";
16
16
  import { recordHandoff, resolveTelemetry } from "@gajae-code/agent-core";
17
17
  import { estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
18
- import type { Message, Model, ServiceTier } from "@gajae-code/ai";
18
+ import type { AssistantMessage, Message, Model, ServiceTier } from "@gajae-code/ai";
19
19
  import { type JsonSchemaValidationIssue, validateJsonSchemaValue } from "@gajae-code/ai/utils/schema";
20
20
  import { logger, prompt, untilAborted } from "@gajae-code/utils";
21
21
  import { AsyncJobManager } from "../async";
@@ -50,6 +50,7 @@ import type { EventBus } from "../utils/event-bus";
50
50
  import { buildNamedToolChoiceResult } from "../utils/tool-choice";
51
51
  import type { WorkspaceTree } from "../workspace-tree";
52
52
  import { validateAllocatedTaskId } from "./id";
53
+ import { classifyProviderRetry, providerNameFromModel } from "./provider-retry-status";
53
54
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
54
55
  import { persistTaskTokenLog, taskTokenLogFromUsage } from "./token-log";
55
56
  import {
@@ -81,6 +82,21 @@ const agentEventTypes = new Set<AgentEvent["type"]>([
81
82
  "tool_execution_end",
82
83
  ]);
83
84
 
85
+ const providerStreamingUpdateTypes = new Set<string>([
86
+ "text_start",
87
+ "text_delta",
88
+ "text_end",
89
+ "thinking_start",
90
+ "thinking_delta",
91
+ "thinking_end",
92
+ "reasoning_summary_start",
93
+ "reasoning_summary_delta",
94
+ "reasoning_summary_end",
95
+ "toolcall_start",
96
+ "toolcall_delta",
97
+ "toolcall_end",
98
+ ]);
99
+
84
100
  const isAgentEvent = (event: AgentSessionEvent): event is AgentEvent =>
85
101
  agentEventTypes.has(event.type as AgentEvent["type"]);
86
102
 
@@ -486,11 +502,12 @@ function buildSchemaViolationOutcome(
486
502
  missing.length > 0
487
503
  ? `schema_violation: missing required fields: ${missing.join(", ")}`
488
504
  : `schema_violation: ${failure.message}`;
505
+ const dataPreview = previewOffendingData(data);
489
506
  const payload = {
490
507
  error: "schema_violation",
491
508
  message: failure.message,
492
509
  missingRequired: missing,
493
- data: previewOffendingData(data),
510
+ data,
494
511
  };
495
512
  let rawOutput: string;
496
513
  try {
@@ -498,7 +515,7 @@ function buildSchemaViolationOutcome(
498
515
  } catch {
499
516
  rawOutput = `{"error":"schema_violation","message":${JSON.stringify(headline)}}`;
500
517
  }
501
- return { rawOutput, stderr: headline, exitCode: 1 };
518
+ return { rawOutput, stderr: `${headline}. Offending data preview: ${dataPreview}`, exitCode: 1 };
502
519
  }
503
520
 
504
521
  function buildPlaceholderYieldOutcome(
@@ -541,9 +558,16 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
541
558
  const completeData = normalizeCompleteData(submitData, reportFindings);
542
559
  const { validator, error: schemaError } = buildOutputValidator(outputSchema);
543
560
  if (schemaError) {
544
- rawOutput = `{"error":"schema_violation","message":"invalid output schema: ${schemaError.replace(/"/g, '\\"')}"}`;
545
- stderr = `schema_violation: invalid output schema: ${schemaError}`;
546
- exitCode = 1;
561
+ const outcome = buildSchemaViolationOutcome(
562
+ {
563
+ message: `invalid output schema: ${schemaError}`,
564
+ missingRequired: [],
565
+ },
566
+ completeData,
567
+ );
568
+ rawOutput = outcome.rawOutput;
569
+ stderr = outcome.stderr;
570
+ exitCode = outcome.exitCode;
547
571
  } else {
548
572
  const placeholderPath = findPlaceholderYieldPath(completeData);
549
573
  if (placeholderPath) {
@@ -837,7 +861,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
837
861
  let modelSubstitutionWarning: ModelSubstitutionWarning | undefined;
838
862
  let resolvedModelString: string | undefined;
839
863
  let lastAssistantModelString: string | undefined;
864
+ let activeProviderModelString: string | undefined;
840
865
  let effectiveThinkingLevelForWarning: ThinkingLevel | undefined;
866
+ let lastProviderProgressAtMs: number | undefined;
867
+ let sessionEventOrdinal = 0;
868
+ let retryStartOrdinal = 0;
869
+ const seenAssistantMessages = new WeakSet<AgentMessage>();
870
+ const seenAssistantMessageIdentities = new Set<string>();
841
871
 
842
872
  // Accumulate usage incrementally from message_end events (no memory for streaming events)
843
873
  const accumulatedUsage = {
@@ -919,7 +949,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
919
949
 
920
950
  const emitProgressNow = () => {
921
951
  progress.durationMs = Date.now() - startTime;
922
- onProgress?.({ ...progress });
952
+ const progressSnapshot = structuredClone(progress);
953
+ onProgress?.(progressSnapshot);
923
954
  if (options.eventBus) {
924
955
  options.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
925
956
  index,
@@ -927,7 +958,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
927
958
  agentSource: agent.source,
928
959
  task,
929
960
  assignment,
930
- progress: { ...progress },
961
+ progress: progressSnapshot,
931
962
  sessionFile: null,
932
963
  });
933
964
  }
@@ -982,6 +1013,79 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
982
1013
  : undefined;
983
1014
  };
984
1015
 
1016
+ const getAssistantMessageIdentity = (message: AgentMessage): string | undefined => {
1017
+ if (message.role !== "assistant") return undefined;
1018
+ const model = getMessageModelString(message);
1019
+ const timestamp = "timestamp" in message ? message.timestamp : undefined;
1020
+ if (!model || typeof timestamp !== "number") return undefined;
1021
+ const responseId = "responseId" in message && typeof message.responseId === "string" ? message.responseId : "";
1022
+ return JSON.stringify([model, timestamp, responseId]);
1023
+ };
1024
+
1025
+ const rememberAssistantMessage = (message: AgentMessage): void => {
1026
+ seenAssistantMessages.add(message);
1027
+ const identity = getAssistantMessageIdentity(message);
1028
+ if (identity) seenAssistantMessageIdentities.add(identity);
1029
+ };
1030
+
1031
+ const isProviderAssistantMessage = (message: AgentMessage): message is AssistantMessage =>
1032
+ message.role === "assistant" &&
1033
+ "stopReason" in message &&
1034
+ Array.isArray(message.content) &&
1035
+ getMessageModelString(message) !== undefined;
1036
+
1037
+ const isSuccessfulProviderAssistantMessage = (message: AgentMessage): message is AssistantMessage =>
1038
+ isProviderAssistantMessage(message) &&
1039
+ message.stopReason !== "error" &&
1040
+ message.stopReason !== "aborted" &&
1041
+ message.errorMessage === undefined &&
1042
+ message.errorKind === undefined &&
1043
+ message.transportFailure === undefined;
1044
+
1045
+ const hasMatchingActiveProviderModel = (message: AgentMessage): message is AssistantMessage =>
1046
+ isProviderAssistantMessage(message) &&
1047
+ activeProviderModelString !== undefined &&
1048
+ getMessageModelString(message) === activeProviderModelString;
1049
+
1050
+ const isProviderStreamingUpdate = (event: Extract<AgentEvent, { type: "message_update" }>): boolean => {
1051
+ const update = event.assistantMessageEvent;
1052
+ if (!update || typeof update !== "object" || !providerStreamingUpdateTypes.has(update.type)) return false;
1053
+ if (!("partial" in update) || getMessageModelString(update.partial) !== activeProviderModelString) return false;
1054
+ switch (update.type) {
1055
+ case "text_delta":
1056
+ case "thinking_delta":
1057
+ case "reasoning_summary_delta":
1058
+ case "toolcall_delta":
1059
+ return typeof update.delta === "string";
1060
+ default:
1061
+ return typeof update.contentIndex === "number";
1062
+ }
1063
+ };
1064
+
1065
+ const getProviderTextDelta = (event: Extract<AgentEvent, { type: "message_update" }>): string | undefined => {
1066
+ const update = (event as { assistantMessageEvent?: unknown }).assistantMessageEvent;
1067
+ if (!update || typeof update !== "object") return undefined;
1068
+ const record = update as { type?: unknown; delta?: unknown };
1069
+ return record.type === "text_delta" && typeof record.delta === "string" ? record.delta : undefined;
1070
+ };
1071
+
1072
+ const isProviderBackedRecoveryEvent = (
1073
+ event: Extract<AgentEvent, { type: "message_start" | "message_update" }>,
1074
+ eventOrdinal: number,
1075
+ ): boolean => {
1076
+ const messageIdentity = getAssistantMessageIdentity(event.message);
1077
+ if (
1078
+ !progress.retryState ||
1079
+ eventOrdinal <= retryStartOrdinal ||
1080
+ seenAssistantMessages.has(event.message) ||
1081
+ (messageIdentity !== undefined && seenAssistantMessageIdentities.has(messageIdentity))
1082
+ )
1083
+ return false;
1084
+ if (!hasMatchingActiveProviderModel(event.message)) return false;
1085
+ if (event.type === "message_start") return isSuccessfulProviderAssistantMessage(event.message);
1086
+ return isProviderStreamingUpdate(event);
1087
+ };
1088
+
985
1089
  const updateRecentOutputLines = () => {
986
1090
  const lines = recentOutputTail.split("\n").filter(line => line.trim());
987
1091
  progress.recentOutput = lines.slice(-8).reverse();
@@ -996,21 +1100,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
996
1100
  updateRecentOutputLines();
997
1101
  };
998
1102
 
999
- const replaceRecentOutputFromContent = (content: unknown[]) => {
1000
- recentOutputTail = "";
1001
- for (const block of content) {
1002
- if (!block || typeof block !== "object") continue;
1003
- const record = block as { type?: unknown; text?: unknown };
1004
- if (record.type !== "text" || typeof record.text !== "string") continue;
1005
- if (!record.text) continue;
1006
- recentOutputTail += record.text;
1007
- if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
1008
- recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
1009
- }
1010
- }
1011
- updateRecentOutputLines();
1012
- };
1013
-
1014
1103
  const resetRecentOutput = () => {
1015
1104
  recentOutputTail = "";
1016
1105
  progress.recentOutput = [];
@@ -1030,20 +1119,32 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1030
1119
  });
1031
1120
  };
1032
1121
 
1033
- const processEvent = (event: AgentEvent) => {
1122
+ const processEvent = (event: AgentEvent, eventOrdinal: number) => {
1034
1123
  if (resolved) return;
1035
1124
 
1036
1125
  forwardSubagentEvent(event);
1037
1126
 
1038
1127
  const now = Date.now();
1039
1128
  let flushProgress = false;
1129
+ // A retry is recovered at the first assistant provider event, before the
1130
+ // session emits auto_retry_end after message completion.
1040
1131
 
1041
1132
  switch (event.type) {
1042
- case "message_start":
1043
- if (event.message?.role === "assistant") {
1133
+ case "message_start": {
1134
+ if (event.message.role !== "assistant") break;
1135
+ const retryWasActive = Boolean(progress.retryState);
1136
+ const recoversRetry = isProviderBackedRecoveryEvent(event, eventOrdinal);
1137
+ rememberAssistantMessage(event.message);
1138
+ if (recoversRetry || !retryWasActive) {
1139
+ lastProviderProgressAtMs = now;
1044
1140
  resetRecentOutput();
1045
1141
  }
1142
+ if (recoversRetry) {
1143
+ progress.retryState = undefined;
1144
+ flushProgress = true;
1145
+ }
1046
1146
  break;
1147
+ }
1047
1148
 
1048
1149
  case "tool_execution_start": {
1049
1150
  progress.toolCount++;
@@ -1155,24 +1256,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1155
1256
  }
1156
1257
 
1157
1258
  case "message_update": {
1158
- if (event.message?.role !== "assistant") break;
1159
- const assistantEvent = (
1160
- event as AgentEvent & {
1161
- assistantMessageEvent?: { type?: string; delta?: string };
1162
- }
1163
- ).assistantMessageEvent;
1164
- if (assistantEvent?.type === "text_delta" && typeof assistantEvent.delta === "string") {
1165
- appendRecentOutputTail(assistantEvent.delta);
1166
- break;
1167
- }
1168
- if (assistantEvent && assistantEvent.type !== "text_delta") {
1169
- break;
1170
- }
1171
- const updateContent =
1172
- getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
1173
- if (updateContent && Array.isArray(updateContent)) {
1174
- replaceRecentOutputFromContent(updateContent);
1259
+ if (event.message.role !== "assistant") break;
1260
+ const retryWasActive = Boolean(progress.retryState);
1261
+ const recoversRetry = isProviderBackedRecoveryEvent(event, eventOrdinal);
1262
+ if (!retryWasActive) rememberAssistantMessage(event.message);
1263
+ if (recoversRetry || !retryWasActive) lastProviderProgressAtMs = now;
1264
+ if (recoversRetry) {
1265
+ progress.retryState = undefined;
1266
+ flushProgress = true;
1175
1267
  }
1268
+ const textDelta = getProviderTextDelta(event);
1269
+ if (textDelta !== undefined) appendRecentOutputTail(textDelta);
1176
1270
  break;
1177
1271
  }
1178
1272
 
@@ -1180,6 +1274,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1180
1274
  // Extract text from assistant and toolResult messages (not user prompts)
1181
1275
  const role = event.message?.role;
1182
1276
  if (role === "assistant") {
1277
+ if (isSuccessfulProviderAssistantMessage(event.message)) lastProviderProgressAtMs = now;
1183
1278
  const messageContent =
1184
1279
  getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
1185
1280
  if (messageContent && Array.isArray(messageContent)) {
@@ -1192,6 +1287,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1192
1287
  const assistantModel = getMessageModelString(event.message);
1193
1288
  if (assistantModel) {
1194
1289
  lastAssistantModelString = assistantModel;
1290
+ activeProviderModelString = assistantModel;
1195
1291
  if (resolvedModelString && assistantModel !== resolvedModelString && !modelSubstitutionWarning) {
1196
1292
  modelSubstitutionWarning = {
1197
1293
  requested: resolvedModelString,
@@ -1359,6 +1455,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1359
1455
  );
1360
1456
  if (model) {
1361
1457
  resolvedModelString = formatModelString(model);
1458
+ activeProviderModelString = resolvedModelString;
1362
1459
  }
1363
1460
  if (authFallbackUsed && model && requestedModel) {
1364
1461
  modelSubstitutionWarning = {
@@ -1496,6 +1593,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1496
1593
  ? { requestedModel, reason: modelSubstitutionWarning.reason }
1497
1594
  : undefined,
1498
1595
  toolNames,
1596
+ alwaysActiveToolNames: ircEnabled ? ["irc"] : undefined,
1499
1597
  outputSchema,
1500
1598
  requireYieldTool: true,
1501
1599
  contextFiles: options.contextFiles,
@@ -1729,11 +1827,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1729
1827
 
1730
1828
  const MAX_YIELD_RETRIES = 3;
1731
1829
  unsubscribe = session.subscribe(event => {
1830
+ sessionEventOrdinal += 1;
1831
+ const eventOrdinal = sessionEventOrdinal;
1732
1832
  if (event.type === "auto_retry_start") {
1833
+ retryStartOrdinal = eventOrdinal;
1834
+ for (const message of session.messages) {
1835
+ if (message.role === "assistant") rememberAssistantMessage(message);
1836
+ }
1733
1837
  progress.retryState = {
1734
1838
  attempt: event.attempt,
1735
1839
  maxAttempts: event.maxAttempts,
1736
1840
  unbounded: event.unbounded,
1841
+ kind: classifyProviderRetry(event.errorMessage),
1842
+ provider: providerNameFromModel(
1843
+ activeProviderModelString ?? lastAssistantModelString ?? resolvedModelString,
1844
+ ),
1845
+ lastProviderProgressAtMs,
1737
1846
  delayMs: event.delayMs,
1738
1847
  errorMessage: event.errorMessage,
1739
1848
  startedAtMs: Date.now(),
@@ -1755,12 +1864,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1755
1864
  return;
1756
1865
  }
1757
1866
  if (event.type === "model_fallback_switched") {
1867
+ activeProviderModelString = event.to;
1758
1868
  forwardSubagentEvent(event);
1759
1869
  return;
1760
1870
  }
1761
1871
  if (isAgentEvent(event)) {
1762
1872
  try {
1763
- processEvent(event);
1873
+ processEvent(event, eventOrdinal);
1764
1874
  } catch (err) {
1765
1875
  logger.error("Subagent event processing failed", {
1766
1876
  error: err instanceof Error ? err.message : String(err),
@@ -0,0 +1,88 @@
1
+ import type { AgentProgress, TaskToolDetails } from "./types";
2
+
3
+ export type ProviderRetryKind = NonNullable<AgentProgress["retryState"]>["kind"];
4
+
5
+ const FIRST_EVENT_TIMEOUT_PATTERN = /stream timed out while waiting for the first event/i;
6
+ const IDLE_STREAM_STALL_PATTERN = /stream stalled while waiting for the next event/i;
7
+
8
+ export function classifyProviderRetry(errorMessage: string): ProviderRetryKind {
9
+ if (FIRST_EVENT_TIMEOUT_PATTERN.test(errorMessage)) return "first_event_timeout";
10
+ if (IDLE_STREAM_STALL_PATTERN.test(errorMessage)) return "idle_stream_stall";
11
+ return "provider_error";
12
+ }
13
+
14
+ export function providerNameFromModel(model: string | undefined): string | undefined {
15
+ const provider = model?.split("/", 1)[0]?.trim();
16
+ return provider || undefined;
17
+ }
18
+
19
+ export function providerRetryPhaseLabel(kind: ProviderRetryKind): string {
20
+ switch (kind) {
21
+ case "first_event_timeout":
22
+ return "first event timeout";
23
+ case "idle_stream_stall":
24
+ return "stream stalled";
25
+ case "provider_error":
26
+ return "provider error";
27
+ }
28
+ }
29
+
30
+ export function providerProgressAgeLabel(retryState: NonNullable<AgentProgress["retryState"]>, nowMs: number): string {
31
+ if (retryState.lastProviderProgressAtMs === undefined) return "no provider events yet";
32
+ const ageSeconds = Math.max(0, Math.floor((nowMs - retryState.lastProviderProgressAtMs) / 1000));
33
+ return `last provider progress ${ageSeconds}s ago`;
34
+ }
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return Boolean(value) && typeof value === "object";
38
+ }
39
+
40
+ function isTaskToolDetails(value: unknown): value is TaskToolDetails {
41
+ return (
42
+ isRecord(value) && Array.isArray(value.results) && (value.progress === undefined || Array.isArray(value.progress))
43
+ );
44
+ }
45
+
46
+ function isAgentProgress(value: unknown): value is AgentProgress {
47
+ return isRecord(value) && typeof value.status === "string";
48
+ }
49
+
50
+ export function hasActiveProviderRetryInTaskDetails(details: TaskToolDetails, seen = new WeakSet<object>()): boolean {
51
+ if (!isTaskToolDetails(details) || seen.has(details)) return false;
52
+ seen.add(details);
53
+ for (const progress of details.progress ?? []) {
54
+ if (isAgentProgress(progress) && hasActiveProviderRetryInProgress(progress, seen)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ export function hasActiveProviderRetryInProgress(progress: AgentProgress, seen = new WeakSet<object>()): boolean {
60
+ if (!isAgentProgress(progress) || seen.has(progress)) return false;
61
+ seen.add(progress);
62
+ if (progress.status === "running" && progress.retryState) return true;
63
+
64
+ const nestedTaskData = isRecord(progress.extractedToolData) ? progress.extractedToolData.task : undefined;
65
+ if (Array.isArray(nestedTaskData)) {
66
+ for (const nestedDetails of nestedTaskData) {
67
+ if (hasActiveProviderRetryInTaskDetails(nestedDetails as TaskToolDetails, seen)) return true;
68
+ }
69
+ }
70
+ return hasActiveProviderRetryInTaskDetails(progress.inflightTaskDetails as TaskToolDetails, seen);
71
+ }
72
+
73
+ export interface ProviderDegradationGroup {
74
+ provider: string;
75
+ count: number;
76
+ }
77
+
78
+ export function collectProviderDegradationGroups(progress: readonly AgentProgress[]): ProviderDegradationGroup[] {
79
+ const counts = new Map<string, number>();
80
+ for (const item of progress) {
81
+ if (!isAgentProgress(item) || item.status !== "running" || !item.retryState) continue;
82
+ const provider = item.retryState.provider ?? "provider";
83
+ counts.set(provider, (counts.get(provider) ?? 0) + 1);
84
+ }
85
+ return Array.from(counts, ([provider, count]) => ({ provider, count }))
86
+ .filter(group => group.count > 1)
87
+ .sort((a, b) => b.count - a.count || a.provider.localeCompare(b.provider));
88
+ }
@@ -28,6 +28,12 @@ import {
28
28
  type SubmitReviewDetails,
29
29
  } from "../tools/review";
30
30
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
31
+ import {
32
+ collectProviderDegradationGroups,
33
+ hasActiveProviderRetryInTaskDetails,
34
+ providerProgressAgeLabel,
35
+ providerRetryPhaseLabel,
36
+ } from "./provider-retry-status";
31
37
  import type { TaskResultReceipt } from "./receipt";
32
38
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
33
39
  import type { AgentProgress, TaskParams, TaskToolDetails } from "./types";
@@ -123,6 +129,15 @@ function formatModelSubstitutionWarning(warning: { requested: string; effective:
123
129
  return `Requested model substituted: ${warning.requested} -> ${warning.effective}`;
124
130
  }
125
131
 
132
+ function renderProviderDegradationNotices(progress: readonly AgentProgress[], theme: Theme): string[] {
133
+ return collectProviderDegradationGroups(progress).map(group =>
134
+ theme.fg(
135
+ "warning",
136
+ truncateToWidth(replaceTabs(`provider degraded: ${group.count} subagents retrying on ${group.provider}`), 100),
137
+ ),
138
+ );
139
+ }
140
+
126
141
  function formatJsonScalar(value: unknown, _theme: Theme): string {
127
142
  if (value === null) return "null";
128
143
  if (typeof value === "string") {
@@ -548,14 +563,11 @@ function renderAgentProgress(
548
563
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
549
564
  let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
550
565
 
551
- // Show retry-blocked badge so the parent immediately sees that a child
552
- // is sleeping on a provider 429, not silently progressing. Wins over the
553
- // generic running spinner because "we're waiting on a quota window" is
554
- // the operationally meaningful state.
566
+ // A provider recovery loop is operationally distinct from normal agent work.
555
567
  if (progress.retryState && progress.status === "running") {
556
- statusLine += ` ${formatBadge("retrying", "warning", theme)}`;
568
+ statusLine += ` ${formatBadge("provider degraded", "warning", theme)}`;
557
569
  } else if (progress.retryFailure && (progress.status === "failed" || progress.status === "aborted")) {
558
- statusLine += ` ${formatBadge("rate-limited", "error", theme)}`;
570
+ statusLine += ` ${formatBadge("provider failed", "error", theme)}`;
559
571
  } else if (progress.status === "failed" || progress.status === "aborted") {
560
572
  const statusLabel = progress.status === "failed" ? "failed" : "aborted";
561
573
  statusLine += ` ${formatBadge(statusLabel, iconColor, theme)}`;
@@ -615,8 +627,10 @@ function renderAgentProgress(
615
627
  // keep spinning while a child sleeps on a 3-hour provider rate-limit.
616
628
  if (progress.retryState && progress.status === "running") {
617
629
  const attemptLabel = progress.retryState.unbounded
618
- ? `attempt ${progress.retryState.attempt}`
619
- : `${progress.retryState.attempt}/${progress.retryState.maxAttempts}`;
630
+ ? `attempt ${progress.retryState.attempt}, unbounded`
631
+ : `attempt ${progress.retryState.attempt} of ${progress.retryState.maxAttempts}, bounded`;
632
+ const phaseLabel = providerRetryPhaseLabel(progress.retryState.kind);
633
+ const progressAge = staticTime ? "" : ` · ${providerProgressAgeLabel(progress.retryState, Date.now())}`;
620
634
  // `staticTime` omits the wall-clock countdown so a cached await body stays a
621
635
  // pure function of its key (the producer already drops time-only churn).
622
636
  let waitLabel = "";
@@ -624,7 +638,7 @@ function renderAgentProgress(
624
638
  const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now());
625
639
  waitLabel = remainingMs > 0 ? ` in ${formatDuration(remainingMs)}` : " now";
626
640
  }
627
- const summary = `retrying ${attemptLabel}${waitLabel}: ${truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60)}`;
641
+ const summary = `${phaseLabel} · retrying ${attemptLabel}${waitLabel}${progressAge}: ${truncateToWidth(replaceTabs(progress.retryState.errorMessage), 60)}`;
628
642
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
629
643
  } else if (progress.retryFailure && progress.status !== "running") {
630
644
  const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
@@ -953,23 +967,28 @@ export function renderResult(
953
967
  .u32(spinnerFrame ?? 0)
954
968
  .u32(width)
955
969
  .digest();
956
- if (cached?.key === key) return cached.lines;
970
+ const hasDynamicRetry = hasActiveProviderRetryInTaskDetails(details);
971
+ if (!hasDynamicRetry && cached?.key === key) return cached.lines;
957
972
 
958
973
  const lines: string[] = [];
959
974
 
960
- const shouldRenderProgress =
961
- Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
975
+ const hasProgress = Boolean(details.progress && details.progress.length > 0);
976
+ const shouldRenderProgress = hasProgress && (isPartial || details.results.length === 0);
977
+ const hasResults = details.results.length > 0;
978
+ if (hasResults) {
979
+ details.results.forEach((res, i) => {
980
+ const isLast = !shouldRenderProgress && i === details.results.length - 1;
981
+ lines.push(...renderAgentResult(res, isLast, expanded, theme));
982
+ });
983
+ }
962
984
  if (shouldRenderProgress && details.progress) {
985
+ lines.push(...renderProviderDegradationNotices(details.progress, theme));
963
986
  details.progress.forEach((progress, i) => {
964
987
  const isLast = i === details.progress!.length - 1;
965
988
  lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame));
966
989
  });
967
- } else if (details.results && details.results.length > 0) {
968
- details.results.forEach((res, i) => {
969
- const isLast = i === details.results.length - 1;
970
- lines.push(...renderAgentResult(res, isLast, expanded, theme));
971
- });
972
-
990
+ }
991
+ if (hasResults && !shouldRenderProgress) {
973
992
  const abortedCount = details.results.filter(r => r.status === "aborted").length;
974
993
  const mergeFailedCount = details.results.filter(r => r.status === "merge_failed").length;
975
994
  const successCount = details.results.filter(r => r.status === "completed").length;
@@ -997,7 +1016,7 @@ export function renderResult(
997
1016
  if (lines.length === 0) {
998
1017
  const text = fallbackText.trim() ? fallbackText : "No results";
999
1018
  const result = [theme.fg("dim", truncateToWidth(text, width))];
1000
- cached = { key, lines: result };
1019
+ if (!hasDynamicRetry) cached = { key, lines: result };
1001
1020
  return result;
1002
1021
  }
1003
1022
 
@@ -1019,9 +1038,9 @@ export function renderResult(
1019
1038
  }
1020
1039
 
1021
1040
  const indented = lines.map(line =>
1022
- line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "",
1041
+ line.length > 0 ? truncateToWidth(` ${replaceTabs(line)}`, width, Ellipsis.Omit) : "",
1023
1042
  );
1024
- cached = { key, lines: indented };
1043
+ if (!hasDynamicRetry) cached = { key, lines: indented };
1025
1044
  return indented;
1026
1045
  },
1027
1046
  invalidate() {
@@ -1065,16 +1084,16 @@ function renderNestedTaskTree(
1065
1084
  ): string[] {
1066
1085
  const lines: string[] = [];
1067
1086
  for (const details of detailsList) {
1068
- const hasResults = Boolean(details.results && details.results.length > 0);
1069
- if (hasResults) {
1087
+ const inflight = details.progress;
1088
+ const hasInflight = Boolean(inflight && inflight.length > 0);
1089
+ if (details.results && details.results.length > 0) {
1070
1090
  details.results.forEach((result, index) => {
1071
- const isLast = index === details.results.length - 1;
1091
+ const isLast = !hasInflight && index === details.results.length - 1;
1072
1092
  lines.push(...renderAgentResult(result, isLast, expanded, theme));
1073
1093
  });
1074
- continue;
1075
1094
  }
1076
- const inflight = details.progress;
1077
1095
  if (inflight && inflight.length > 0) {
1096
+ lines.push(...renderProviderDegradationNotices(inflight, theme));
1078
1097
  inflight.forEach((prog, index) => {
1079
1098
  const isLast = index === inflight.length - 1;
1080
1099
  lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame, staticTime));
package/src/task/types.ts CHANGED
@@ -285,6 +285,9 @@ export interface AgentProgress {
285
285
  attempt: number;
286
286
  maxAttempts: number;
287
287
  unbounded?: boolean;
288
+ kind: "first_event_timeout" | "idle_stream_stall" | "provider_error";
289
+ provider?: string;
290
+ lastProviderProgressAtMs?: number;
288
291
  delayMs: number;
289
292
  errorMessage: string;
290
293
  startedAtMs: number;
package/src/tools/ask.ts CHANGED
@@ -461,6 +461,7 @@ function recoverRoundZeroIntentContract(
461
461
  const deepInterview = question.deepInterview;
462
462
  const hasIntentContract = Object.hasOwn(deepInterview, "intent_contract");
463
463
  const hasIntentReview = Object.hasOwn(deepInterview, "intent_review");
464
+ if (hasIntentContract && hasIntentReview && stage !== "topology") return { outcome: "reject" };
464
465
  if (hasIntentContract !== hasIntentReview && askSchema.safeParse(normalizedArguments).success)
465
466
  return { outcome: "passthrough" };
466
467