@dsh-enhanced/assistant-delivery 0.1.18 → 0.1.20

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.
@@ -37,6 +37,15 @@ const PROGRESS_ASSIGNMENT_START = new RegExp(`(^|[^A-Za-z0-9_])(["']?)${PROGRESS
37
37
  const ESCAPED_QUOTED_PROGRESS_ASSIGNMENT_START = new RegExp(`(^|[^A-Za-z0-9_])\\\\+(["'])${PROGRESS_ASSIGNMENT_KEY}\\\\+\\2(\\s*[:=]\\s*)`, 'giu');
38
38
  class DurableAgentIdentityError extends Error {
39
39
  }
40
+ class AutomaticCompletionUnavailableError extends Error {
41
+ }
42
+ class AutomaticCompletionAuthorizationError extends Error {
43
+ state;
44
+ constructor(state) {
45
+ super(`assistant-delivery: automatic completion authorization is ${state}`);
46
+ this.state = state;
47
+ }
48
+ }
40
49
  class ApprovalReviewerReaderUnavailableError extends Error {
41
50
  constructor(cause) {
42
51
  super('assistant-delivery: approval reviewer persistence reader is not proven', { cause });
@@ -854,22 +863,140 @@ function sessionResumeFailureCode(error) {
854
863
  function sessionFingerprint(value) {
855
864
  return createHash('sha256').update(value).digest('hex').slice(0, 8);
856
865
  }
866
+ const AGENT_OUTPUT_TRUNCATED_CODE = 'agent-output-truncated';
867
+ const AGENT_EMPTY_RESPONSE_CODE = 'agent-empty-response';
868
+ const AUTOMATIC_COMPLETION_TOOL_DENIAL = 'assistant-delivery: automatic answer completion disables tool execution';
869
+ function utf8Prefix(value, maxBytes) {
870
+ const bytes = Buffer.from(value, 'utf8');
871
+ if (bytes.length <= maxBytes)
872
+ return value;
873
+ let end = maxBytes;
874
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80)
875
+ end -= 1;
876
+ return bytes.subarray(0, end).toString('utf8');
877
+ }
878
+ function fittingNotice(candidates, maxBytes) {
879
+ return candidates.find(value => Buffer.byteLength(value, 'utf8') <= maxBytes) ?? '!';
880
+ }
881
+ function truncatedAgentReply(text, maxBytes) {
882
+ const notice = fittingNotice([
883
+ '⚠️ 本次回答未能完整生成;以下内容可能不完整。',
884
+ '本次回答未完整生成。',
885
+ '回答不完整。',
886
+ 'retry',
887
+ '!',
888
+ ], maxBytes);
889
+ if (text.trim() === '')
890
+ return fittingNotice([
891
+ '本次未能生成可发送的完整回答。请稍后重试或缩小问题范围。',
892
+ '本次未能生成完整回答,请重试。',
893
+ '未生成回答,请重试。',
894
+ 'retry',
895
+ '!',
896
+ ], maxBytes);
897
+ const separator = '\n\n> ';
898
+ const suffix = `${separator}${notice}`;
899
+ const prefixBudget = maxBytes - Buffer.byteLength(suffix, 'utf8');
900
+ if (prefixBudget <= 0)
901
+ return notice;
902
+ const prefix = utf8Prefix(text, prefixBudget);
903
+ return prefix.trim() === '' ? notice : `${prefix}${suffix}`;
904
+ }
905
+ function emptyAgentReply(maxBytes) {
906
+ return fittingNotice([
907
+ '模型本轮未生成可发送的回答。请重试;如果问题较长,也可以拆成更小的步骤。',
908
+ '模型未生成回答,请重试。',
909
+ '请重试。',
910
+ 'retry',
911
+ '!',
912
+ ], maxBytes);
913
+ }
914
+ function mergeContinuationText(current, continuation) {
915
+ if (current === '')
916
+ return { text: continuation, added: continuation !== '' };
917
+ if (continuation === '')
918
+ return { text: current, added: false };
919
+ if (continuation.startsWith(current)) {
920
+ const suffix = continuation.slice(current.length);
921
+ const added = suffix.trim() !== '';
922
+ return { text: added ? continuation : current, added };
923
+ }
924
+ if (current.endsWith(continuation))
925
+ return { text: current, added: false };
926
+ const currentPoints = [...current];
927
+ const continuationPoints = [...continuation];
928
+ const maxOverlap = Math.min(currentPoints.length, continuationPoints.length, 4_096);
929
+ for (let length = maxOverlap; length >= 2; length -= 1) {
930
+ const currentOverlap = currentPoints.slice(-length);
931
+ if (length < 16) {
932
+ const hanCount = currentOverlap.filter(point => /^\p{Script=Han}$/u.test(point)).length;
933
+ if (hanCount < 2 || currentOverlap.some(point => !/^[\p{Script=Han}\p{P}\p{Z}]$/u.test(point)))
934
+ continue;
935
+ }
936
+ if (currentOverlap.join('') !== continuationPoints.slice(0, length).join(''))
937
+ continue;
938
+ const suffix = continuationPoints.slice(length).join('');
939
+ const added = suffix.trim() !== '';
940
+ return { text: added ? current + suffix : current, added };
941
+ }
942
+ return { text: current + continuation, added: continuation.trim() !== '' };
943
+ }
944
+ function automaticCompletionPrompt(kind, maxTextBytes, maxOutputTokens) {
945
+ const marker = '[DSH automatic answer completion notice]';
946
+ const scope = 'Work only on the current task: the most recent user request before the first marked notice '
947
+ + 'in this trailing recovery sequence, plus assistant answer segments after that request. '
948
+ + 'Ignore all earlier completed tasks, and treat every marked notice as an instruction rather than answer content.';
949
+ const common = 'Do not call tools, repeat actions, discuss this recovery instruction, or add a preamble.';
950
+ if (kind === 'continue') {
951
+ return `${marker} ${scope} The immediately preceding assistant answer segment was cut off by an output limit. `
952
+ + `Continue exactly from its last character and finish the current answer. Return only the missing remainder `
953
+ + `without repeating earlier text. ${common}`;
954
+ }
955
+ if (kind === 'compact') {
956
+ const approximateCjkCharacters = Math.max(1, Math.floor(maxTextBytes / 4));
957
+ const targetTokens = Math.max(1, Math.min(Math.floor(maxOutputTokens / 2), approximateCjkCharacters));
958
+ return `${marker} ${scope} Rewrite the current task result as one complete, self-contained, concise final answer `
959
+ + `using at most ${targetTokens} tokens `
960
+ + `and under ${maxTextBytes} UTF-8 bytes (about ${approximateCjkCharacters} CJK characters). `
961
+ + 'Preserve the conclusion and essential facts, '
962
+ + `but remove repetition and nonessential detail. ${common}`;
963
+ }
964
+ return `${marker} ${scope} The preceding attempt produced no sendable answer. Answer the current request now `
965
+ + `with one complete, self-contained and concise final response. ${common}`;
966
+ }
857
967
  function finalAssistant(events, from, expectedTurn) {
858
- if (expectedTurn === undefined)
859
- return { text: '', completed: false, stopped: false };
968
+ if (expectedTurn === undefined) {
969
+ return { text: '', completed: false, truncated: false, stopped: false, hasUnpairedToolCall: false };
970
+ }
860
971
  let text = '';
861
972
  let completed = false;
973
+ let truncated = false;
862
974
  let stopped = false;
975
+ const unpairedToolCalls = new Set();
863
976
  let failureCode;
864
977
  for (const event of events.slice(from)) {
865
978
  if (event.type === 'assistant/message' && event.data.turn === expectedTurn) {
866
979
  text = event.data.message.content.filter(block => block.type === 'text')
867
980
  .map(block => block.type === 'text' ? block.text : '').join('');
981
+ for (const block of event.data.message.content) {
982
+ if (block.type === 'tool-call')
983
+ unpairedToolCalls.add(String(block.id));
984
+ }
985
+ }
986
+ if (event.type === 'tool/result' && event.data.turn === expectedTurn) {
987
+ const block = event.data.message.content.find(value => value.type === 'tool-result');
988
+ const callId = block?.type === 'tool-result'
989
+ ? block.toolCallId
990
+ : event.data.message.source?.callId;
991
+ if (callId !== undefined)
992
+ unpairedToolCalls.delete(String(callId));
868
993
  }
869
994
  if (event.type === 'turn/end' && event.data.turn === expectedTurn) {
870
995
  const reason = event.data.reason;
871
996
  completed = typeof reason === 'object' && reason !== null && 'kind' in reason
872
- && ['completed', 'max-tokens'].includes(reason.kind);
997
+ && reason.kind === 'completed';
998
+ truncated = typeof reason === 'object' && reason !== null && 'kind' in reason
999
+ && reason.kind === 'max-tokens';
873
1000
  stopped = typeof reason === 'object' && reason !== null
874
1001
  && reason.kind === 'aborted'
875
1002
  && typeof reason.reason === 'object'
@@ -878,10 +1005,17 @@ function finalAssistant(events, from, expectedTurn) {
878
1005
  const error = typeof reason === 'object' && reason !== null && 'error' in reason
879
1006
  ? reason.error
880
1007
  : undefined;
881
- failureCode = completed || typeof error?.code !== 'string' ? undefined : error.code;
1008
+ failureCode = completed || truncated || typeof error?.code !== 'string' ? undefined : error.code;
882
1009
  }
883
1010
  }
884
- return { text, completed, stopped, ...(failureCode === undefined ? {} : { failureCode }) };
1011
+ return {
1012
+ text,
1013
+ completed,
1014
+ truncated,
1015
+ stopped,
1016
+ hasUnpairedToolCall: unpairedToolCalls.size > 0,
1017
+ ...(failureCode === undefined ? {} : { failureCode }),
1018
+ };
885
1019
  }
886
1020
  export class DshDeliveryRuntime {
887
1021
  ctx;
@@ -973,7 +1107,7 @@ export class DshDeliveryRuntime {
973
1107
  }
974
1108
  }
975
1109
  /** Wait for only the turn that claims Delivery's exact message identity. */
976
- followupTurn(agent, message) {
1110
+ followupTurn(agent, message, onClaimed, onSettled) {
977
1111
  return new Promise((resolve, reject) => {
978
1112
  let claimedTurn;
979
1113
  let settled = false;
@@ -997,15 +1131,32 @@ export class DshDeliveryRuntime {
997
1131
  reject(error);
998
1132
  };
999
1133
  const removeClaimed = this.ctx.on('agent/inbox/claimed', event => {
1000
- if (event.agent === agent && event.message.id === message.id)
1134
+ if (event.agent === agent && event.message.id === message.id) {
1001
1135
  claimedTurn = event.turn;
1136
+ onClaimed?.(event.turn);
1137
+ }
1002
1138
  });
1003
1139
  const removeDiscarded = this.ctx.on('agent/inbox/discarded', event => {
1004
- if (event.agent === agent && event.message.id === message.id)
1005
- settle(undefined);
1140
+ if (event.agent !== agent || event.message.id !== message.id)
1141
+ return;
1142
+ try {
1143
+ onSettled?.();
1144
+ }
1145
+ catch (error) {
1146
+ fail(error);
1147
+ return;
1148
+ }
1149
+ settle(undefined);
1006
1150
  });
1007
1151
  const removeSessionEvent = this.ctx.on('session/event', (session, event) => {
1008
1152
  if (session === agent.session && event.type === 'turn/end' && event.data.turn === claimedTurn) {
1153
+ try {
1154
+ onSettled?.();
1155
+ }
1156
+ catch (error) {
1157
+ fail(error);
1158
+ return;
1159
+ }
1009
1160
  settle(claimedTurn);
1010
1161
  }
1011
1162
  });
@@ -1017,6 +1168,129 @@ export class DshDeliveryRuntime {
1017
1168
  }
1018
1169
  });
1019
1170
  }
1171
+ /** Wait only for scheduling quiescence; exact turn ownership still comes from followupTurn(). */
1172
+ async waitForAgentIdle(agent, signal) {
1173
+ signal.throwIfAborted();
1174
+ while (true) {
1175
+ await new Promise((resolve, reject) => {
1176
+ let settled = false;
1177
+ const cleanup = () => signal.removeEventListener('abort', aborted);
1178
+ const finish = () => {
1179
+ if (settled)
1180
+ return;
1181
+ settled = true;
1182
+ cleanup();
1183
+ resolve();
1184
+ };
1185
+ const fail = (error) => {
1186
+ if (settled)
1187
+ return;
1188
+ settled = true;
1189
+ cleanup();
1190
+ reject(error);
1191
+ };
1192
+ const aborted = () => {
1193
+ try {
1194
+ signal.throwIfAborted();
1195
+ }
1196
+ catch (error) {
1197
+ fail(error);
1198
+ }
1199
+ };
1200
+ signal.addEventListener('abort', aborted, { once: true });
1201
+ void agent.whenIdle().then(finish, fail);
1202
+ if (signal.aborted)
1203
+ aborted();
1204
+ });
1205
+ signal.throwIfAborted();
1206
+ if (agent.status === 'idle')
1207
+ return;
1208
+ }
1209
+ }
1210
+ /**
1211
+ * Queue a synthetic completion turn only after the previous exact turn retired. rc.8 may still
1212
+ * serialize scoped tool schemas, but every call is denied before approval/execution while this
1213
+ * turn owns the Agent, so recovering prose cannot repeat an external side effect.
1214
+ */
1215
+ async automaticCompletionTurn(agent, message, signal, expectedPreviousTurn, authorize, onClaimed) {
1216
+ await this.waitForAgentIdle(agent, signal);
1217
+ signal.throwIfAborted();
1218
+ let liftPreExecute;
1219
+ let liftRestriction;
1220
+ let liftGuard;
1221
+ let submitted = false;
1222
+ let exactTurnSettled = false;
1223
+ const releaseToolBarriers = () => {
1224
+ let firstError;
1225
+ if (liftGuard !== undefined) {
1226
+ try {
1227
+ liftGuard();
1228
+ liftGuard = undefined;
1229
+ }
1230
+ catch (error) {
1231
+ firstError ??= error;
1232
+ }
1233
+ }
1234
+ if (liftRestriction !== undefined) {
1235
+ try {
1236
+ liftRestriction();
1237
+ liftRestriction = undefined;
1238
+ }
1239
+ catch (error) {
1240
+ firstError ??= error;
1241
+ }
1242
+ }
1243
+ if (liftPreExecute !== undefined) {
1244
+ try {
1245
+ liftPreExecute();
1246
+ liftPreExecute = undefined;
1247
+ }
1248
+ catch (error) {
1249
+ firstError ??= error;
1250
+ }
1251
+ }
1252
+ if (firstError !== undefined)
1253
+ throw firstError;
1254
+ };
1255
+ try {
1256
+ liftPreExecute = agent.ctx.on('tools/pre-execute', () => Promise.resolve({
1257
+ kind: 'deny',
1258
+ reason: AUTOMATIC_COMPLETION_TOOL_DENIAL,
1259
+ }), { prepend: true });
1260
+ liftRestriction = agent.ctx.tools.restrict({ allow: [] });
1261
+ liftGuard = agent.ctx.tools.guard(() => AUTOMATIC_COMPLETION_TOOL_DENIAL);
1262
+ signal.throwIfAborted();
1263
+ if (agent.status !== 'idle' || agent.inbox.hasPending) {
1264
+ throw new AutomaticCompletionUnavailableError('assistant-delivery: automatic completion lost the idle scheduling boundary');
1265
+ }
1266
+ const latestBoundary = agent.session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end');
1267
+ if (latestBoundary?.type !== 'turn/end' || latestBoundary.data.turn !== expectedPreviousTurn) {
1268
+ throw new AutomaticCompletionUnavailableError('assistant-delivery: another turn crossed the automatic completion boundary');
1269
+ }
1270
+ const authorization = authorize();
1271
+ if (authorization !== 'authorized')
1272
+ throw new AutomaticCompletionAuthorizationError(authorization);
1273
+ submitted = true;
1274
+ return await this.followupTurn(agent, message, onClaimed, () => {
1275
+ exactTurnSettled = true;
1276
+ releaseToolBarriers();
1277
+ });
1278
+ }
1279
+ catch (error) {
1280
+ if (signal.aborted)
1281
+ signal.throwIfAborted();
1282
+ if (submitted || error instanceof AutomaticCompletionAuthorizationError
1283
+ || error instanceof AutomaticCompletionUnavailableError)
1284
+ throw error;
1285
+ throw new AutomaticCompletionUnavailableError('assistant-delivery: automatic completion safety guard was unavailable', { cause: error });
1286
+ }
1287
+ finally {
1288
+ // A submitted message may already be pending or running even when followup() throws.
1289
+ // Keep the barriers attached to the Agent scope until exact settlement or handle teardown.
1290
+ if (!submitted || exactTurnSettled)
1291
+ releaseToolBarriers();
1292
+ }
1293
+ }
1020
1294
  async setupAgent(agentCtx, workspace, presetId, principal, selected, agentPresets) {
1021
1295
  const agent = agentCtx.agent;
1022
1296
  if (agent === undefined)
@@ -2332,10 +2606,31 @@ export class DshDeliveryRuntime {
2332
2606
  if (authorizationFailure !== undefined)
2333
2607
  return authorizationFailure;
2334
2608
  }
2335
- const from = agent.session.events.length;
2609
+ let turnFrom = agent.session.events.length;
2610
+ const automaticCompletionTurns = new Map();
2611
+ const automaticCompletionToolViolations = new Set();
2336
2612
  removeProgress = this.ctx.on('session/event', (session, event) => {
2337
2613
  if (session !== agent.session)
2338
2614
  return;
2615
+ if (event.type === 'step/start') {
2616
+ const automaticKind = automaticCompletionTurns.get(event.data.turn);
2617
+ if (automaticKind !== undefined) {
2618
+ publishProgress({
2619
+ kind: 'step',
2620
+ text: automaticKind === 'compact' ? '正在压缩并补全回答…' : '正在自动补全回答…',
2621
+ });
2622
+ return;
2623
+ }
2624
+ }
2625
+ if (event.type === 'tool/call' && automaticCompletionTurns.has(event.data.turn)) {
2626
+ if (!automaticCompletionToolViolations.has(event.data.turn)) {
2627
+ automaticCompletionToolViolations.add(event.data.turn);
2628
+ agent.cancel({ kind: 'hook', reason: 'assistant-delivery-automatic-completion-tool-call' }, { keepInbox: true });
2629
+ }
2630
+ return;
2631
+ }
2632
+ if (event.type === 'tool/result' && automaticCompletionTurns.has(event.data.turn))
2633
+ return;
2339
2634
  const update = deliveryProgressFromSessionEvent(event);
2340
2635
  if (update !== undefined)
2341
2636
  publishProgress(update);
@@ -2361,34 +2656,182 @@ export class DshDeliveryRuntime {
2361
2656
  kind: 'delivery', channel: envelope.channel, account: envelope.account, eventId: envelope.eventId,
2362
2657
  trust: 'untrusted',
2363
2658
  } });
2364
- const deliveryTurn = await this.followupTurn(agent, message);
2365
- const output = finalAssistant(agent.session.events, from, deliveryTurn);
2366
- if (!await sessions.flush(agent.session)) {
2367
- publishProgress({ kind: 'failed', code: 'session-persistence-unavailable' });
2368
- return { outcome: 'not-processed', failureCode: 'session-flush-failed', retryable: false };
2369
- }
2370
- const cancelled = this.activeSessionControls.get(binding.sessionId)?.command;
2371
- if (cancelled !== undefined || output.stopped) {
2372
- publishProgress({ kind: 'failed', code: cancelled === 'new' ? 'new-session' : 'user-stopped' });
2373
- return { outcome: 'processed' };
2374
- }
2375
- const finalAuthorization = checkAuthorization();
2376
- if (finalAuthorization !== 'authorized') {
2377
- return finalAuthorization === 'revoked'
2378
- ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
2379
- : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: false };
2380
- }
2381
- if (!output.completed) {
2382
- publishProgress({ kind: 'failed', ...(output.failureCode === undefined ? {} : { code: output.failureCode }) });
2383
- return { outcome: 'not-processed', failureCode: 'agent-turn-incomplete', retryable: false };
2659
+ let deliveryTurn = await this.followupTurn(agent, message);
2660
+ let completionKind;
2661
+ let automaticTurns = 0;
2662
+ let assembledText = '';
2663
+ let bestEffortText = '';
2664
+ while (true) {
2665
+ const output = finalAssistant(agent.session.events, turnFrom, deliveryTurn);
2666
+ let addedText = false;
2667
+ if (output.text.trim() !== '') {
2668
+ if (completionKind === 'compact' || completionKind === 'regenerate') {
2669
+ const previousText = assembledText;
2670
+ addedText = output.text.trim() !== previousText.trim();
2671
+ if (addedText)
2672
+ assembledText = output.text;
2673
+ }
2674
+ else {
2675
+ const merged = mergeContinuationText(assembledText, output.text);
2676
+ assembledText = merged.text;
2677
+ addedText = merged.added;
2678
+ }
2679
+ bestEffortText = assembledText;
2680
+ }
2681
+ // Every exact segment crosses the persistence barrier before another paid model turn can start.
2682
+ if (!await sessions.flush(agent.session)) {
2683
+ publishProgress({ kind: 'failed', code: 'session-persistence-unavailable' });
2684
+ return { outcome: 'not-processed', failureCode: 'session-flush-failed', retryable: false };
2685
+ }
2686
+ signal.throwIfAborted();
2687
+ const cancelled = this.activeSessionControls.get(binding.sessionId)?.command;
2688
+ const automaticToolViolation = deliveryTurn !== undefined
2689
+ && automaticCompletionToolViolations.has(deliveryTurn);
2690
+ if (cancelled !== undefined || (output.stopped && !automaticToolViolation)) {
2691
+ publishProgress({ kind: 'failed', code: cancelled === 'new' ? 'new-session' : 'user-stopped' });
2692
+ return { outcome: 'processed' };
2693
+ }
2694
+ const finalAuthorization = checkAuthorization();
2695
+ if (finalAuthorization !== 'authorized') {
2696
+ return finalAuthorization === 'revoked'
2697
+ ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
2698
+ : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: false };
2699
+ }
2700
+ const currentEmpty = output.text.trim() === '';
2701
+ const oversized = Buffer.byteLength(assembledText, 'utf8') > this.options.maxTextBytes;
2702
+ const stalledRecovery = completionKind !== undefined && output.completed && !currentEmpty && !addedText;
2703
+ const recoverable = automaticToolViolation || output.truncated
2704
+ || (output.completed && (currentEmpty || oversized || stalledRecovery));
2705
+ if (recoverable && deliveryTurn !== undefined && !output.hasUnpairedToolCall
2706
+ && automaticTurns < this.options.maxAutoContinuationTurns) {
2707
+ const isLastAutomaticTurn = automaticTurns + 1 >= this.options.maxAutoContinuationTurns;
2708
+ const nextKind = oversized
2709
+ ? 'compact'
2710
+ : assembledText.trim() === ''
2711
+ ? 'regenerate'
2712
+ : isLastAutomaticTurn
2713
+ ? 'compact'
2714
+ : 'continue';
2715
+ automaticTurns += 1;
2716
+ turnFrom = agent.session.events.length;
2717
+ const completionMessage = createUserMessage({
2718
+ content: [{
2719
+ type: 'text',
2720
+ text: automaticCompletionPrompt(nextKind, this.options.maxTextBytes, this.options.maxOutputTokens),
2721
+ }],
2722
+ source: {
2723
+ kind: 'plugin',
2724
+ plugin: '@dsh-enhanced/assistant-delivery',
2725
+ form: 'notice',
2726
+ summary: `Automatic answer completion attempt ${automaticTurns}`,
2727
+ },
2728
+ });
2729
+ completionKind = nextKind;
2730
+ try {
2731
+ deliveryTurn = await this.automaticCompletionTurn(agent, completionMessage, signal, deliveryTurn, checkAuthorization, turn => automaticCompletionTurns.set(turn, nextKind));
2732
+ }
2733
+ catch (error) {
2734
+ if (error instanceof AutomaticCompletionAuthorizationError) {
2735
+ return error.state === 'revoked'
2736
+ ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
2737
+ : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: false };
2738
+ }
2739
+ if (!(error instanceof AutomaticCompletionUnavailableError))
2740
+ throw error;
2741
+ signal.throwIfAborted();
2742
+ const cancellation = this.activeSessionControls.get(binding.sessionId)?.command;
2743
+ if (cancellation !== undefined) {
2744
+ publishProgress({ kind: 'failed', code: cancellation === 'new' ? 'new-session' : 'user-stopped' });
2745
+ return { outcome: 'processed' };
2746
+ }
2747
+ const fallbackAuthorization = checkAuthorization();
2748
+ if (fallbackAuthorization !== 'authorized') {
2749
+ return fallbackAuthorization === 'revoked'
2750
+ ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
2751
+ : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: false };
2752
+ }
2753
+ const hasPartial = bestEffortText.trim() !== '';
2754
+ this.options.replyCommand(binding, envelope.eventId, {
2755
+ text: hasPartial
2756
+ ? truncatedAgentReply(bestEffortText, this.options.maxTextBytes)
2757
+ : emptyAgentReply(this.options.maxTextBytes),
2758
+ format: 'markdown',
2759
+ });
2760
+ publishProgress({
2761
+ kind: 'failed',
2762
+ code: hasPartial ? AGENT_OUTPUT_TRUNCATED_CODE : AGENT_EMPTY_RESPONSE_CODE,
2763
+ });
2764
+ return { outcome: 'processed' };
2765
+ }
2766
+ continue;
2767
+ }
2768
+ if (automaticToolViolation) {
2769
+ const hasPartial = bestEffortText.trim() !== '';
2770
+ this.options.replyCommand(binding, envelope.eventId, {
2771
+ text: hasPartial
2772
+ ? truncatedAgentReply(bestEffortText, this.options.maxTextBytes)
2773
+ : emptyAgentReply(this.options.maxTextBytes),
2774
+ format: 'markdown',
2775
+ });
2776
+ publishProgress({
2777
+ kind: 'failed',
2778
+ code: hasPartial ? AGENT_OUTPUT_TRUNCATED_CODE : AGENT_EMPTY_RESPONSE_CODE,
2779
+ });
2780
+ return { outcome: 'processed' };
2781
+ }
2782
+ if (output.truncated || oversized) {
2783
+ this.options.replyCommand(binding, envelope.eventId, {
2784
+ text: truncatedAgentReply(bestEffortText, this.options.maxTextBytes),
2785
+ format: 'markdown',
2786
+ });
2787
+ publishProgress({ kind: 'failed', code: AGENT_OUTPUT_TRUNCATED_CODE });
2788
+ return { outcome: 'processed' };
2789
+ }
2790
+ if (stalledRecovery) {
2791
+ this.options.replyCommand(binding, envelope.eventId, {
2792
+ text: truncatedAgentReply(bestEffortText, this.options.maxTextBytes),
2793
+ format: 'markdown',
2794
+ });
2795
+ publishProgress({ kind: 'failed', code: AGENT_OUTPUT_TRUNCATED_CODE });
2796
+ return { outcome: 'processed' };
2797
+ }
2798
+ if (!output.completed) {
2799
+ if (automaticTurns > 0 && bestEffortText.trim() !== '') {
2800
+ this.options.replyCommand(binding, envelope.eventId, {
2801
+ text: truncatedAgentReply(bestEffortText, this.options.maxTextBytes),
2802
+ format: 'markdown',
2803
+ });
2804
+ publishProgress({
2805
+ kind: 'failed',
2806
+ code: output.failureCode ?? AGENT_OUTPUT_TRUNCATED_CODE,
2807
+ });
2808
+ return { outcome: 'processed' };
2809
+ }
2810
+ publishProgress({ kind: 'failed', ...(output.failureCode === undefined ? {} : { code: output.failureCode }) });
2811
+ return { outcome: 'not-processed', failureCode: 'agent-turn-incomplete', retryable: false };
2812
+ }
2813
+ if (currentEmpty || assembledText.trim() === '') {
2814
+ const hasPartial = bestEffortText.trim() !== '';
2815
+ this.options.replyCommand(binding, envelope.eventId, {
2816
+ text: hasPartial
2817
+ ? truncatedAgentReply(bestEffortText, this.options.maxTextBytes)
2818
+ : emptyAgentReply(this.options.maxTextBytes),
2819
+ format: 'markdown',
2820
+ });
2821
+ publishProgress({
2822
+ kind: 'failed',
2823
+ code: hasPartial ? AGENT_OUTPUT_TRUNCATED_CODE : AGENT_EMPTY_RESPONSE_CODE,
2824
+ });
2825
+ return { outcome: 'processed' };
2826
+ }
2827
+ break;
2384
2828
  }
2829
+ signal.throwIfAborted();
2385
2830
  // Agent answers are authored as Markdown (tables, bold, inline code), so request Markdown
2386
2831
  // rendering; sending them as plain text shows the raw `|---|` and `**` syntax to the user.
2387
- if (output.text !== '') {
2388
- const learning = await this.options.replyCompletedPreferenceTurn(agent, binding, envelope, { text: output.text, format: 'markdown' });
2389
- if (learning === 'unknown') {
2390
- this.ctx.logger.warn('assistant-delivery: completed-turn preference projection is ambiguous');
2391
- }
2832
+ const learning = await this.options.replyCompletedPreferenceTurn(agent, binding, envelope, { text: assembledText, format: 'markdown' });
2833
+ if (learning === 'unknown') {
2834
+ this.ctx.logger.warn('assistant-delivery: completed-turn preference projection is ambiguous');
2392
2835
  }
2393
2836
  publishProgress({ kind: 'completed' });
2394
2837
  return { outcome: 'processed' };