@gajae-code/coding-agent 0.11.9 → 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.
- package/CHANGELOG.md +18 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +8 -1
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
- package/dist/types/sdk/session.d.ts +7 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
- package/dist/types/task/provider-retry-status.d.ts +13 -0
- package/dist/types/task/types.d.ts +3 -0
- package/dist/types/tools/index.d.ts +3 -1
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +1 -0
- package/src/cli/notify-cli.ts +1 -0
- package/src/config/model-profiles.ts +7 -7
- package/src/config/model-registry.ts +4 -2
- package/src/eval/js/tool-bridge.ts +1 -1
- package/src/gjc-runtime/deep-interview-draft.ts +2 -1
- package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
- package/src/gjc-runtime/state-writer.ts +1 -1
- package/src/hooks/skill-state.ts +23 -1
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +38 -8
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/architect.md +8 -1
- package/src/prompts/agents/critic.md +8 -1
- package/src/prompts/agents/planner.md +8 -1
- package/src/sdk/broker/identity.ts +22 -12
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/index.ts +60 -5
- package/src/sdk/bus/notification-service.ts +19 -5
- package/src/sdk/bus/operator-runtime.ts +31 -5
- package/src/sdk/bus/telegram-cli.ts +6 -13
- package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
- package/src/sdk/bus/telegram-daemon-control.ts +14 -5
- package/src/sdk/bus/telegram-daemon.ts +667 -118
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +12 -0
- package/src/session/agent-session.ts +54 -10
- package/src/session/internal/managed-session-scope.ts +133 -117
- package/src/skill-state/workflow-mutation-guard.ts +41 -7
- package/src/task/executor.ts +141 -39
- package/src/task/provider-retry-status.ts +88 -0
- package/src/task/render.ts +45 -26
- package/src/task/types.ts +3 -0
- package/src/tools/bash-allowed-prefixes.ts +45 -1
- package/src/tools/index.ts +3 -1
- package/src/tools/subagent-render.ts +74 -16
- package/src/tools/subagent.ts +2 -0
- package/vendor/insane-search/MANIFEST.json +4 -1
- package/vendor/insane-search/engine/bias_check.py +5 -0
- package/vendor/insane-search/engine/fetch_chain.py +67 -1
- package/vendor/insane-search/engine/transport.py +13 -2
- package/vendor/insane-search/engine/validators.py +4 -1
package/src/task/executor.ts
CHANGED
|
@@ -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
|
|
|
@@ -845,7 +861,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
845
861
|
let modelSubstitutionWarning: ModelSubstitutionWarning | undefined;
|
|
846
862
|
let resolvedModelString: string | undefined;
|
|
847
863
|
let lastAssistantModelString: string | undefined;
|
|
864
|
+
let activeProviderModelString: string | undefined;
|
|
848
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>();
|
|
849
871
|
|
|
850
872
|
// Accumulate usage incrementally from message_end events (no memory for streaming events)
|
|
851
873
|
const accumulatedUsage = {
|
|
@@ -927,7 +949,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
927
949
|
|
|
928
950
|
const emitProgressNow = () => {
|
|
929
951
|
progress.durationMs = Date.now() - startTime;
|
|
930
|
-
|
|
952
|
+
const progressSnapshot = structuredClone(progress);
|
|
953
|
+
onProgress?.(progressSnapshot);
|
|
931
954
|
if (options.eventBus) {
|
|
932
955
|
options.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
|
|
933
956
|
index,
|
|
@@ -935,7 +958,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
935
958
|
agentSource: agent.source,
|
|
936
959
|
task,
|
|
937
960
|
assignment,
|
|
938
|
-
progress:
|
|
961
|
+
progress: progressSnapshot,
|
|
939
962
|
sessionFile: null,
|
|
940
963
|
});
|
|
941
964
|
}
|
|
@@ -990,6 +1013,79 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
990
1013
|
: undefined;
|
|
991
1014
|
};
|
|
992
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
|
+
|
|
993
1089
|
const updateRecentOutputLines = () => {
|
|
994
1090
|
const lines = recentOutputTail.split("\n").filter(line => line.trim());
|
|
995
1091
|
progress.recentOutput = lines.slice(-8).reverse();
|
|
@@ -1004,21 +1100,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1004
1100
|
updateRecentOutputLines();
|
|
1005
1101
|
};
|
|
1006
1102
|
|
|
1007
|
-
const replaceRecentOutputFromContent = (content: unknown[]) => {
|
|
1008
|
-
recentOutputTail = "";
|
|
1009
|
-
for (const block of content) {
|
|
1010
|
-
if (!block || typeof block !== "object") continue;
|
|
1011
|
-
const record = block as { type?: unknown; text?: unknown };
|
|
1012
|
-
if (record.type !== "text" || typeof record.text !== "string") continue;
|
|
1013
|
-
if (!record.text) continue;
|
|
1014
|
-
recentOutputTail += record.text;
|
|
1015
|
-
if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
|
|
1016
|
-
recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
updateRecentOutputLines();
|
|
1020
|
-
};
|
|
1021
|
-
|
|
1022
1103
|
const resetRecentOutput = () => {
|
|
1023
1104
|
recentOutputTail = "";
|
|
1024
1105
|
progress.recentOutput = [];
|
|
@@ -1038,20 +1119,32 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1038
1119
|
});
|
|
1039
1120
|
};
|
|
1040
1121
|
|
|
1041
|
-
const processEvent = (event: AgentEvent) => {
|
|
1122
|
+
const processEvent = (event: AgentEvent, eventOrdinal: number) => {
|
|
1042
1123
|
if (resolved) return;
|
|
1043
1124
|
|
|
1044
1125
|
forwardSubagentEvent(event);
|
|
1045
1126
|
|
|
1046
1127
|
const now = Date.now();
|
|
1047
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.
|
|
1048
1131
|
|
|
1049
1132
|
switch (event.type) {
|
|
1050
|
-
case "message_start":
|
|
1051
|
-
if (event.message
|
|
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;
|
|
1052
1140
|
resetRecentOutput();
|
|
1053
1141
|
}
|
|
1142
|
+
if (recoversRetry) {
|
|
1143
|
+
progress.retryState = undefined;
|
|
1144
|
+
flushProgress = true;
|
|
1145
|
+
}
|
|
1054
1146
|
break;
|
|
1147
|
+
}
|
|
1055
1148
|
|
|
1056
1149
|
case "tool_execution_start": {
|
|
1057
1150
|
progress.toolCount++;
|
|
@@ -1163,24 +1256,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1163
1256
|
}
|
|
1164
1257
|
|
|
1165
1258
|
case "message_update": {
|
|
1166
|
-
if (event.message
|
|
1167
|
-
const
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
)
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
break;
|
|
1175
|
-
}
|
|
1176
|
-
if (assistantEvent && assistantEvent.type !== "text_delta") {
|
|
1177
|
-
break;
|
|
1178
|
-
}
|
|
1179
|
-
const updateContent =
|
|
1180
|
-
getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
|
|
1181
|
-
if (updateContent && Array.isArray(updateContent)) {
|
|
1182
|
-
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;
|
|
1183
1267
|
}
|
|
1268
|
+
const textDelta = getProviderTextDelta(event);
|
|
1269
|
+
if (textDelta !== undefined) appendRecentOutputTail(textDelta);
|
|
1184
1270
|
break;
|
|
1185
1271
|
}
|
|
1186
1272
|
|
|
@@ -1188,6 +1274,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1188
1274
|
// Extract text from assistant and toolResult messages (not user prompts)
|
|
1189
1275
|
const role = event.message?.role;
|
|
1190
1276
|
if (role === "assistant") {
|
|
1277
|
+
if (isSuccessfulProviderAssistantMessage(event.message)) lastProviderProgressAtMs = now;
|
|
1191
1278
|
const messageContent =
|
|
1192
1279
|
getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
|
|
1193
1280
|
if (messageContent && Array.isArray(messageContent)) {
|
|
@@ -1200,6 +1287,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1200
1287
|
const assistantModel = getMessageModelString(event.message);
|
|
1201
1288
|
if (assistantModel) {
|
|
1202
1289
|
lastAssistantModelString = assistantModel;
|
|
1290
|
+
activeProviderModelString = assistantModel;
|
|
1203
1291
|
if (resolvedModelString && assistantModel !== resolvedModelString && !modelSubstitutionWarning) {
|
|
1204
1292
|
modelSubstitutionWarning = {
|
|
1205
1293
|
requested: resolvedModelString,
|
|
@@ -1367,6 +1455,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1367
1455
|
);
|
|
1368
1456
|
if (model) {
|
|
1369
1457
|
resolvedModelString = formatModelString(model);
|
|
1458
|
+
activeProviderModelString = resolvedModelString;
|
|
1370
1459
|
}
|
|
1371
1460
|
if (authFallbackUsed && model && requestedModel) {
|
|
1372
1461
|
modelSubstitutionWarning = {
|
|
@@ -1504,6 +1593,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1504
1593
|
? { requestedModel, reason: modelSubstitutionWarning.reason }
|
|
1505
1594
|
: undefined,
|
|
1506
1595
|
toolNames,
|
|
1596
|
+
alwaysActiveToolNames: ircEnabled ? ["irc"] : undefined,
|
|
1507
1597
|
outputSchema,
|
|
1508
1598
|
requireYieldTool: true,
|
|
1509
1599
|
contextFiles: options.contextFiles,
|
|
@@ -1737,11 +1827,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1737
1827
|
|
|
1738
1828
|
const MAX_YIELD_RETRIES = 3;
|
|
1739
1829
|
unsubscribe = session.subscribe(event => {
|
|
1830
|
+
sessionEventOrdinal += 1;
|
|
1831
|
+
const eventOrdinal = sessionEventOrdinal;
|
|
1740
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
|
+
}
|
|
1741
1837
|
progress.retryState = {
|
|
1742
1838
|
attempt: event.attempt,
|
|
1743
1839
|
maxAttempts: event.maxAttempts,
|
|
1744
1840
|
unbounded: event.unbounded,
|
|
1841
|
+
kind: classifyProviderRetry(event.errorMessage),
|
|
1842
|
+
provider: providerNameFromModel(
|
|
1843
|
+
activeProviderModelString ?? lastAssistantModelString ?? resolvedModelString,
|
|
1844
|
+
),
|
|
1845
|
+
lastProviderProgressAtMs,
|
|
1745
1846
|
delayMs: event.delayMs,
|
|
1746
1847
|
errorMessage: event.errorMessage,
|
|
1747
1848
|
startedAtMs: Date.now(),
|
|
@@ -1763,12 +1864,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1763
1864
|
return;
|
|
1764
1865
|
}
|
|
1765
1866
|
if (event.type === "model_fallback_switched") {
|
|
1867
|
+
activeProviderModelString = event.to;
|
|
1766
1868
|
forwardSubagentEvent(event);
|
|
1767
1869
|
return;
|
|
1768
1870
|
}
|
|
1769
1871
|
if (isAgentEvent(event)) {
|
|
1770
1872
|
try {
|
|
1771
|
-
processEvent(event);
|
|
1873
|
+
processEvent(event, eventOrdinal);
|
|
1772
1874
|
} catch (err) {
|
|
1773
1875
|
logger.error("Subagent event processing failed", {
|
|
1774
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
|
+
}
|
package/src/task/render.ts
CHANGED
|
@@ -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
|
-
//
|
|
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("
|
|
568
|
+
statusLine += ` ${formatBadge("provider degraded", "warning", theme)}`;
|
|
557
569
|
} else if (progress.retryFailure && (progress.status === "failed" || progress.status === "aborted")) {
|
|
558
|
-
statusLine += ` ${formatBadge("
|
|
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
|
-
:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
961
|
-
|
|
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
|
-
}
|
|
968
|
-
|
|
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
|
|
1069
|
-
|
|
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;
|
|
@@ -18,7 +18,14 @@ export interface BashRestrictionOptions {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const SHELL_CONTROL_CHARS = new Set([";", "|", "&", "<", ">", "(", ")"]);
|
|
21
|
-
|
|
21
|
+
// `~` is handled separately: bash performs tilde expansion only at positions where a
|
|
22
|
+
// tilde can open an expansion - the start of a word, and inside an assignment word
|
|
23
|
+
// directly after the first `=` or after a `:` in the assigned value. A tilde anywhere
|
|
24
|
+
// else (e.g. the git revision `HEAD~1`) is a literal character.
|
|
25
|
+
const UNSAFE_UNQUOTED_EXPANSION_CHARS = new Set(["$", "*", "?", "[", "]", "{", "}"]);
|
|
26
|
+
// A bash assignment word has a plain assignment name before its first unquoted `=`,
|
|
27
|
+
// in either the `name=value` or the compound `name+=value` form.
|
|
28
|
+
const ASSIGNMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*\+?$/u;
|
|
22
29
|
const ALLOWED_STATE_ACTIONS = new Set(["read", "write", "contract"]);
|
|
23
30
|
const CANONICAL_STATE_TARGETS = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS);
|
|
24
31
|
const READ_ONLY_COMMANDS = new Set(["grep", "rg", "tree", "ls", "pwd", "wc", "du", "file", "stat"]);
|
|
@@ -29,6 +36,14 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
|
|
|
29
36
|
let quote: "single" | "double" | null = null;
|
|
30
37
|
|
|
31
38
|
let wordStarted = false;
|
|
39
|
+
// Per-token tilde-expansion state. `assignmentNameCandidate` stays true while the
|
|
40
|
+
// characters before the first unquoted `=` could still form an assignment name (any
|
|
41
|
+
// quoting in that region disqualifies the token), `sawAssignmentEquals` records that
|
|
42
|
+
// the token is an assignment word, and `tildeExpandable` means an unquoted `~` at the
|
|
43
|
+
// current index would actually be expanded by bash.
|
|
44
|
+
let assignmentNameCandidate = true;
|
|
45
|
+
let sawAssignmentEquals = false;
|
|
46
|
+
let tildeExpandable = false;
|
|
32
47
|
for (let index = 0; index < command.length; index += 1) {
|
|
33
48
|
const char = command[index]!;
|
|
34
49
|
const next = command[index + 1];
|
|
@@ -63,11 +78,15 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
|
|
|
63
78
|
if (char === "'") {
|
|
64
79
|
quote = "single";
|
|
65
80
|
wordStarted = true;
|
|
81
|
+
if (!sawAssignmentEquals) assignmentNameCandidate = false;
|
|
82
|
+
tildeExpandable = false;
|
|
66
83
|
continue;
|
|
67
84
|
}
|
|
68
85
|
if (char === '"') {
|
|
69
86
|
quote = "double";
|
|
70
87
|
wordStarted = true;
|
|
88
|
+
if (!sawAssignmentEquals) assignmentNameCandidate = false;
|
|
89
|
+
tildeExpandable = false;
|
|
71
90
|
continue;
|
|
72
91
|
}
|
|
73
92
|
if (char === "`" || (char === "$" && next === "(")) {
|
|
@@ -82,17 +101,42 @@ function parseShellWords(command: string): { words: string[]; reason?: string }
|
|
|
82
101
|
if (UNSAFE_UNQUOTED_EXPANSION_CHARS.has(char)) {
|
|
83
102
|
return { words, reason: `shell expansion character '${char}' is not allowed in restricted bash commands` };
|
|
84
103
|
}
|
|
104
|
+
// Tilde expansion is positional: bash expands a tilde only where one can open an
|
|
105
|
+
// expansion, i.e. at the start of a word or, inside an assignment word, directly
|
|
106
|
+
// after the first `=` or after a `:` in the assigned value. A tilde anywhere else
|
|
107
|
+
// is literal, which is what git revision syntax such as `HEAD~1` relies on.
|
|
108
|
+
if (char === "~" && (!wordStarted || tildeExpandable)) {
|
|
109
|
+
return { words, reason: "shell expansion character '~' is not allowed in restricted bash commands" };
|
|
110
|
+
}
|
|
85
111
|
if (/\s/u.test(char)) {
|
|
86
112
|
if (wordStarted) {
|
|
87
113
|
words.push(current);
|
|
88
114
|
current = "";
|
|
89
115
|
wordStarted = false;
|
|
90
116
|
}
|
|
117
|
+
assignmentNameCandidate = true;
|
|
118
|
+
sawAssignmentEquals = false;
|
|
119
|
+
tildeExpandable = false;
|
|
91
120
|
continue;
|
|
92
121
|
}
|
|
93
122
|
if (char === "\\") {
|
|
94
123
|
return { words, reason: "backslash escapes are not allowed in restricted bash commands" };
|
|
95
124
|
}
|
|
125
|
+
if (char === "=" && !sawAssignmentEquals) {
|
|
126
|
+
// Only a plain assignment name before the first unquoted `=` makes this an
|
|
127
|
+
// assignment word whose value bash scans for tilde expansions.
|
|
128
|
+
if (assignmentNameCandidate && ASSIGNMENT_NAME_PATTERN.test(current)) {
|
|
129
|
+
sawAssignmentEquals = true;
|
|
130
|
+
tildeExpandable = true;
|
|
131
|
+
} else {
|
|
132
|
+
assignmentNameCandidate = false;
|
|
133
|
+
tildeExpandable = false;
|
|
134
|
+
}
|
|
135
|
+
} else if (char === ":" && sawAssignmentEquals) {
|
|
136
|
+
tildeExpandable = true;
|
|
137
|
+
} else {
|
|
138
|
+
tildeExpandable = false;
|
|
139
|
+
}
|
|
96
140
|
current += char;
|
|
97
141
|
wordStarted = true;
|
|
98
142
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -275,9 +275,11 @@ export interface ToolSession {
|
|
|
275
275
|
/** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
|
|
276
276
|
getAgentId?: () => string | null;
|
|
277
277
|
/** Look up a registered tool by name (used by the eval js backend's tool bridge). */
|
|
278
|
+
getToolByName?: (name: string) => AgentTool | undefined;
|
|
279
|
+
/** Look up a registered tool with the session's execution guards applied. */
|
|
280
|
+
getToolForExecution?: (name: string) => AgentTool | undefined;
|
|
278
281
|
/** Purge undelivered queued custom messages matching the predicate. Returns counts. */
|
|
279
282
|
purgeQueuedCustomMessages?: (predicate: (message: CustomMessage) => boolean) => PurgeQueuedCustomMessagesResult;
|
|
280
|
-
getToolByName?: (name: string) => AgentTool | undefined;
|
|
281
283
|
/** Agent registry for IRC routing across live sessions. */
|
|
282
284
|
agentRegistry?: AgentRegistry;
|
|
283
285
|
/** Optional restricted bash command prefixes for read-only role agents and constrained modes. */
|