@dsh-enhanced/assistant-delivery 0.1.19 → 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 });
@@ -856,6 +865,7 @@ function sessionFingerprint(value) {
856
865
  }
857
866
  const AGENT_OUTPUT_TRUNCATED_CODE = 'agent-output-truncated';
858
867
  const AGENT_EMPTY_RESPONSE_CODE = 'agent-empty-response';
868
+ const AUTOMATIC_COMPLETION_TOOL_DENIAL = 'assistant-delivery: automatic answer completion disables tool execution';
859
869
  function utf8Prefix(value, maxBytes) {
860
870
  const bytes = Buffer.from(value, 'utf8');
861
871
  if (bytes.length <= maxBytes)
@@ -870,16 +880,16 @@ function fittingNotice(candidates, maxBytes) {
870
880
  }
871
881
  function truncatedAgentReply(text, maxBytes) {
872
882
  const notice = fittingNotice([
873
- '⚠️ 本轮输出达到上限,回答可能不完整。请回复“继续”,我会接着回答。',
874
- '回答达到上限,可能不完整;请回复“继续”。',
875
- '回答被截断,请继续。',
883
+ '⚠️ 本次回答未能完整生成;以下内容可能不完整。',
884
+ '本次回答未完整生成。',
885
+ '回答不完整。',
876
886
  'retry',
877
887
  '!',
878
888
  ], maxBytes);
879
889
  if (text.trim() === '')
880
890
  return fittingNotice([
881
- '模型本轮输出达到上限,但尚未生成可发送的正文。请回复“继续”或缩小问题范围后重试。',
882
- '输出达到上限且未生成正文,请回复“继续”。',
891
+ '本次未能生成可发送的完整回答。请稍后重试或缩小问题范围。',
892
+ '本次未能生成完整回答,请重试。',
883
893
  '未生成回答,请重试。',
884
894
  'retry',
885
895
  '!',
@@ -901,18 +911,85 @@ function emptyAgentReply(maxBytes) {
901
911
  '!',
902
912
  ], maxBytes);
903
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
+ }
904
967
  function finalAssistant(events, from, expectedTurn) {
905
- if (expectedTurn === undefined)
906
- return { text: '', completed: false, truncated: false, stopped: false };
968
+ if (expectedTurn === undefined) {
969
+ return { text: '', completed: false, truncated: false, stopped: false, hasUnpairedToolCall: false };
970
+ }
907
971
  let text = '';
908
972
  let completed = false;
909
973
  let truncated = false;
910
974
  let stopped = false;
975
+ const unpairedToolCalls = new Set();
911
976
  let failureCode;
912
977
  for (const event of events.slice(from)) {
913
978
  if (event.type === 'assistant/message' && event.data.turn === expectedTurn) {
914
979
  text = event.data.message.content.filter(block => block.type === 'text')
915
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));
916
993
  }
917
994
  if (event.type === 'turn/end' && event.data.turn === expectedTurn) {
918
995
  const reason = event.data.reason;
@@ -931,7 +1008,14 @@ function finalAssistant(events, from, expectedTurn) {
931
1008
  failureCode = completed || truncated || typeof error?.code !== 'string' ? undefined : error.code;
932
1009
  }
933
1010
  }
934
- return { text, completed, truncated, stopped, ...(failureCode === undefined ? {} : { failureCode }) };
1011
+ return {
1012
+ text,
1013
+ completed,
1014
+ truncated,
1015
+ stopped,
1016
+ hasUnpairedToolCall: unpairedToolCalls.size > 0,
1017
+ ...(failureCode === undefined ? {} : { failureCode }),
1018
+ };
935
1019
  }
936
1020
  export class DshDeliveryRuntime {
937
1021
  ctx;
@@ -1023,7 +1107,7 @@ export class DshDeliveryRuntime {
1023
1107
  }
1024
1108
  }
1025
1109
  /** Wait for only the turn that claims Delivery's exact message identity. */
1026
- followupTurn(agent, message) {
1110
+ followupTurn(agent, message, onClaimed, onSettled) {
1027
1111
  return new Promise((resolve, reject) => {
1028
1112
  let claimedTurn;
1029
1113
  let settled = false;
@@ -1047,15 +1131,32 @@ export class DshDeliveryRuntime {
1047
1131
  reject(error);
1048
1132
  };
1049
1133
  const removeClaimed = this.ctx.on('agent/inbox/claimed', event => {
1050
- if (event.agent === agent && event.message.id === message.id)
1134
+ if (event.agent === agent && event.message.id === message.id) {
1051
1135
  claimedTurn = event.turn;
1136
+ onClaimed?.(event.turn);
1137
+ }
1052
1138
  });
1053
1139
  const removeDiscarded = this.ctx.on('agent/inbox/discarded', event => {
1054
- if (event.agent === agent && event.message.id === message.id)
1055
- 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);
1056
1150
  });
1057
1151
  const removeSessionEvent = this.ctx.on('session/event', (session, event) => {
1058
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
+ }
1059
1160
  settle(claimedTurn);
1060
1161
  }
1061
1162
  });
@@ -1067,6 +1168,129 @@ export class DshDeliveryRuntime {
1067
1168
  }
1068
1169
  });
1069
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
+ }
1070
1294
  async setupAgent(agentCtx, workspace, presetId, principal, selected, agentPresets) {
1071
1295
  const agent = agentCtx.agent;
1072
1296
  if (agent === undefined)
@@ -2382,10 +2606,31 @@ export class DshDeliveryRuntime {
2382
2606
  if (authorizationFailure !== undefined)
2383
2607
  return authorizationFailure;
2384
2608
  }
2385
- const from = agent.session.events.length;
2609
+ let turnFrom = agent.session.events.length;
2610
+ const automaticCompletionTurns = new Map();
2611
+ const automaticCompletionToolViolations = new Set();
2386
2612
  removeProgress = this.ctx.on('session/event', (session, event) => {
2387
2613
  if (session !== agent.session)
2388
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;
2389
2634
  const update = deliveryProgressFromSessionEvent(event);
2390
2635
  if (update !== undefined)
2391
2636
  publishProgress(update);
@@ -2411,46 +2656,180 @@ export class DshDeliveryRuntime {
2411
2656
  kind: 'delivery', channel: envelope.channel, account: envelope.account, eventId: envelope.eventId,
2412
2657
  trust: 'untrusted',
2413
2658
  } });
2414
- const deliveryTurn = await this.followupTurn(agent, message);
2415
- const output = finalAssistant(agent.session.events, from, deliveryTurn);
2416
- if (!await sessions.flush(agent.session)) {
2417
- publishProgress({ kind: 'failed', code: 'session-persistence-unavailable' });
2418
- return { outcome: 'not-processed', failureCode: 'session-flush-failed', retryable: false };
2419
- }
2420
- const cancelled = this.activeSessionControls.get(binding.sessionId)?.command;
2421
- if (cancelled !== undefined || output.stopped) {
2422
- publishProgress({ kind: 'failed', code: cancelled === 'new' ? 'new-session' : 'user-stopped' });
2423
- return { outcome: 'processed' };
2424
- }
2425
- const finalAuthorization = checkAuthorization();
2426
- if (finalAuthorization !== 'authorized') {
2427
- return finalAuthorization === 'revoked'
2428
- ? { outcome: 'not-processed', failureCode: 'inbound-authorization-revoked', retryable: false }
2429
- : { outcome: 'not-processed', failureCode: 'inbound-authorization-check-failed', retryable: false };
2430
- }
2431
- if (output.truncated || Buffer.byteLength(output.text, 'utf8') > this.options.maxTextBytes) {
2432
- this.options.replyCommand(binding, envelope.eventId, {
2433
- text: truncatedAgentReply(output.text, this.options.maxTextBytes),
2434
- format: 'markdown',
2435
- });
2436
- publishProgress({ kind: 'failed', code: AGENT_OUTPUT_TRUNCATED_CODE });
2437
- return { outcome: 'processed' };
2438
- }
2439
- if (!output.completed) {
2440
- publishProgress({ kind: 'failed', ...(output.failureCode === undefined ? {} : { code: output.failureCode }) });
2441
- return { outcome: 'not-processed', failureCode: 'agent-turn-incomplete', retryable: false };
2442
- }
2443
- if (output.text.trim() === '') {
2444
- this.options.replyCommand(binding, envelope.eventId, {
2445
- text: emptyAgentReply(this.options.maxTextBytes),
2446
- format: 'markdown',
2447
- });
2448
- publishProgress({ kind: 'failed', code: AGENT_EMPTY_RESPONSE_CODE });
2449
- return { outcome: 'processed' };
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;
2450
2828
  }
2829
+ signal.throwIfAborted();
2451
2830
  // Agent answers are authored as Markdown (tables, bold, inline code), so request Markdown
2452
2831
  // rendering; sending them as plain text shows the raw `|---|` and `**` syntax to the user.
2453
- const learning = await this.options.replyCompletedPreferenceTurn(agent, binding, envelope, { text: output.text, format: 'markdown' });
2832
+ const learning = await this.options.replyCompletedPreferenceTurn(agent, binding, envelope, { text: assembledText, format: 'markdown' });
2454
2833
  if (learning === 'unknown') {
2455
2834
  this.ctx.logger.warn('assistant-delivery: completed-turn preference projection is ambiguous');
2456
2835
  }