@dsh-enhanced/assistant-delivery 0.1.31 → 0.1.34

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 (41) hide show
  1. package/README.md +13 -1
  2. package/lib/acceptance.d.ts +11 -3
  3. package/lib/acceptance.d.ts.map +1 -1
  4. package/lib/agent-runtime.d.ts +15 -0
  5. package/lib/agent-runtime.d.ts.map +1 -1
  6. package/lib/agent-runtime.js +270 -47
  7. package/lib/agent-runtime.js.map +1 -1
  8. package/lib/foreground-observation.d.ts +32 -0
  9. package/lib/foreground-observation.d.ts.map +1 -0
  10. package/lib/foreground-observation.js +13 -0
  11. package/lib/foreground-observation.js.map +1 -0
  12. package/lib/index.d.ts +6 -1
  13. package/lib/index.d.ts.map +1 -1
  14. package/lib/index.js +2 -0
  15. package/lib/index.js.map +1 -1
  16. package/lib/native-web-owner.d.ts.map +1 -1
  17. package/lib/native-web-owner.js +3 -3
  18. package/lib/native-web-owner.js.map +1 -1
  19. package/lib/reply-replay.d.ts +52 -0
  20. package/lib/reply-replay.d.ts.map +1 -0
  21. package/lib/reply-replay.js +122 -0
  22. package/lib/reply-replay.js.map +1 -0
  23. package/lib/service.d.ts +115 -2
  24. package/lib/service.d.ts.map +1 -1
  25. package/lib/service.js +483 -94
  26. package/lib/service.js.map +1 -1
  27. package/lib/session-lease-runtime.d.ts +2 -1
  28. package/lib/session-lease-runtime.d.ts.map +1 -1
  29. package/lib/session-lease-runtime.js +3 -3
  30. package/lib/session-lease-runtime.js.map +1 -1
  31. package/lib/sqlite.d.ts +1 -1
  32. package/lib/sqlite.d.ts.map +1 -1
  33. package/lib/sqlite.js +146 -3
  34. package/lib/sqlite.js.map +1 -1
  35. package/lib/store.d.ts +151 -10
  36. package/lib/store.d.ts.map +1 -1
  37. package/lib/store.js +520 -7
  38. package/lib/store.js.map +1 -1
  39. package/lib/version.d.ts +1 -1
  40. package/lib/version.js +1 -1
  41. package/package.json +14 -10
@@ -1,7 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, stat } from 'node:fs/promises';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
  import { installModelSelection, } from '@deepseek-ai/dsh-agent';
4
5
  import { contentHasImage, createUserMessage, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
6
+ import { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools';
5
7
  import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy';
6
8
  import { isAppendSurfaceEvent, SessionId } from '@deepseek-ai/dsh-session';
7
9
  import { setApprovalPolicy, } from '@deepseek-ai/dsh-user-approval';
@@ -328,6 +330,13 @@ export function deliveryProgressFromSessionEvent(event) {
328
330
  if (event.type === 'assistant/message') {
329
331
  return undefined;
330
332
  }
333
+ if (event.type === 'approval/asked') {
334
+ // approval/asked is appended before the review waterfall runs, so this label
335
+ // must stay accurate for both the (usually fast) automatic review and a real
336
+ // human escalation (which additionally renders a separate allow/reject card).
337
+ // A tool name here is policy-controlled, but bound it like every other field.
338
+ return { kind: 'step', text: `正在确认工具「${boundedProgressText(event.data.toolName)}」的调用权限…` };
339
+ }
331
340
  if (event.type === 'tool/call') {
332
341
  if (event.data.name === 'ask_user_question') {
333
342
  return { kind: 'step', text: '等待您的回答…' };
@@ -864,6 +873,12 @@ function sessionResumeFailureCode(error) {
864
873
  return 'session-persistence-corrupt';
865
874
  return 'session-resume-unavailable';
866
875
  }
876
+ function sessionFormatUnsupportedReply() {
877
+ return {
878
+ text: '旧会话格式无法由当前 Host 恢复,记录已保留。请使用 /new 开启会话。',
879
+ format: 'plain',
880
+ };
881
+ }
867
882
  function sessionFingerprint(value) {
868
883
  return createHash('sha256').update(value).digest('hex').slice(0, 8);
869
884
  }
@@ -970,13 +985,14 @@ function automaticCompletionPrompt(kind, maxTextBytes, maxOutputTokens) {
970
985
  }
971
986
  function finalAssistant(events, from, expectedTurn) {
972
987
  if (expectedTurn === undefined) {
973
- return { text: '', completed: false, truncated: false, stopped: false, hasUnpairedToolCall: false };
988
+ return { text: '', completed: false, truncated: false, stopped: false, hasUnpairedToolCall: false, hadToolCall: false };
974
989
  }
975
990
  let text = '';
976
991
  let completed = false;
977
992
  let truncated = false;
978
993
  let stopped = false;
979
994
  const unpairedToolCalls = new Set();
995
+ let hadToolCall = false;
980
996
  let failureCode;
981
997
  for (const event of events.slice(from)) {
982
998
  if (event.type === 'assistant/message' && event.data.turn === expectedTurn) {
@@ -984,6 +1000,7 @@ function finalAssistant(events, from, expectedTurn) {
984
1000
  .map(block => block.type === 'text' ? block.text : '').join('');
985
1001
  for (const block of event.data.message.content) {
986
1002
  if (block.type === 'tool-call') {
1003
+ hadToolCall = true;
987
1004
  unpairedToolCalls.add(String(block.id));
988
1005
  }
989
1006
  }
@@ -1019,6 +1036,7 @@ function finalAssistant(events, from, expectedTurn) {
1019
1036
  truncated,
1020
1037
  stopped,
1021
1038
  hasUnpairedToolCall: unpairedToolCalls.size > 0,
1039
+ hadToolCall,
1022
1040
  ...(failureCode === undefined ? {} : { failureCode }),
1023
1041
  };
1024
1042
  }
@@ -1026,6 +1044,58 @@ function isScheduledGoalRuntimeContext(source) {
1026
1044
  return (source.kind === 'plugin' && source.plugin === '@deepseek-ai/dsh-system-prompt' && source.form === 'snapshot')
1027
1045
  || (source.kind === 'skill-catalog' && source.form === 'catalog');
1028
1046
  }
1047
+ /**
1048
+ * Trusted Host clarification injected after the human deliberately cancels a
1049
+ * tool call before it dispatched.
1050
+ *
1051
+ * Why this exists: when a turn is aborted by the human, the agent loop persists
1052
+ * a `tool/result` pair whose ONLY model-visible text is the bare technical
1053
+ * string "Error: tool call aborted before dispatch" (`isError: true`). The
1054
+ * accurate structured facts — `error.code = ABORTED_BEFORE_DISPATCH` and the
1055
+ * `turn/end` reason `{ aborted, reason: { user } }` — are not surface events,
1056
+ * so the model never sees them. A string of those error-looking results makes a
1057
+ * later no-op message (e.g. "hi") look like "retry the fetch the system kept
1058
+ * interrupting", causing the model to re-fire the cancelled tool unprompted.
1059
+ *
1060
+ * This text is a FIXED Host constant. It never interpolates message content,
1061
+ * tool names, or arguments, so untrusted input cannot flow into the trusted
1062
+ * snapshot. The predicate keys only off structured, Host-owned fields.
1063
+ */
1064
+ const USER_CANCELLED_TOOL_NOTICE = [
1065
+ 'The most recent turn was stopped deliberately by the human (they pressed stop or declined the tool permission).',
1066
+ 'Any earlier tool result reading "Error: tool call aborted before dispatch" is that human cancellation, NOT a tool or system failure and NOT an instruction to retry.',
1067
+ 'Do not retry, resume, restart, or continue any tool call or task that was interrupted by that stop.',
1068
+ 'Respond only to the human\'s latest message on its own terms. Resume the earlier work only if the human explicitly asks for it again.',
1069
+ ].join(' ');
1070
+ /** Whether a `turn/end` reason is a human-initiated abort (stop, /new, or a declined approval). */
1071
+ function isUserAbortedTurnEnd(reason) {
1072
+ return typeof reason === 'object' && reason !== null
1073
+ && reason.kind === 'aborted'
1074
+ && typeof reason.reason === 'object'
1075
+ && reason.reason?.kind === 'user';
1076
+ }
1077
+ /**
1078
+ * Returns the fixed Host clarification when the most recent turn boundary is a
1079
+ * human abort whose turn contains an aborted-before-dispatch tool result;
1080
+ * otherwise `''`. Pure over the supplied events so the projection dedups stable
1081
+ * text across repeated no-op messages and flips to empty once a turn completes.
1082
+ */
1083
+ function userCancelledToolNotice(events) {
1084
+ let lastEnd;
1085
+ for (let index = events.length - 1; index >= 0; index -= 1) {
1086
+ const event = events[index];
1087
+ if (event?.type === 'turn/end') {
1088
+ lastEnd = event;
1089
+ break;
1090
+ }
1091
+ }
1092
+ if (lastEnd === undefined || !isUserAbortedTurnEnd(lastEnd.data.reason))
1093
+ return '';
1094
+ const abortedBeforeDispatch = events.some(event => event.type === 'tool/result'
1095
+ && event.data.turn === lastEnd.data.turn
1096
+ && event.data.error?.code === TOOL_ABORTED_BEFORE_DISPATCH);
1097
+ return abortedBeforeDispatch ? USER_CANCELLED_TOOL_NOTICE : '';
1098
+ }
1029
1099
  export class DshDeliveryRuntime {
1030
1100
  ctx;
1031
1101
  policy;
@@ -1385,6 +1455,7 @@ export class DshDeliveryRuntime {
1385
1455
  aborted();
1386
1456
  return {
1387
1457
  settle: beginSettling,
1458
+ pinActiveArmedGoal: pinArmedGoal,
1388
1459
  teardownTimeoutMs,
1389
1460
  isQuiescent,
1390
1461
  dispose: () => {
@@ -1658,8 +1729,8 @@ export class DshDeliveryRuntime {
1658
1729
  releaseToolBarriers();
1659
1730
  }
1660
1731
  }
1661
- async setupAgent(agentCtx, workspace, presetId, principal, selected, agentPresets, initiator = 'external') {
1662
- const agent = agentCtx.agent;
1732
+ async setupAgent(agentCtx, preparedAgent, workspace, presetId, principal, selected, agentPresets, initiator = 'external') {
1733
+ const agent = preparedAgent ?? agentCtx.agent;
1663
1734
  if (agent === undefined)
1664
1735
  throw new Error('assistant-delivery: unpublished Agent identity is missing');
1665
1736
  if (agent.session.header.cwd !== workspace || agent.session.header.agentPreset !== presetId) {
@@ -1668,6 +1739,36 @@ export class DshDeliveryRuntime {
1668
1739
  const unbind = this.policy.bindInitiator(agent, initiator, principal);
1669
1740
  agentCtx.effect(() => unbind, `assistant-delivery.${initiator}-initiator`);
1670
1741
  installModelSelection(agentCtx, { current: selected, assembled: undefined });
1742
+ // The trusted post-cancellation clarification is installed LAZILY and only
1743
+ // for a session whose history actually contains a human-aborted turn with an
1744
+ // aborted-before-dispatch tool result. Merely registering a runtime context
1745
+ // flips the system-prompt assembly out of its empty baseline and perturbs the
1746
+ // cold-resume/lease-recovery accounting for EVERY ordinary session (a
1747
+ // permission /stop compensation run has no such tool result, yet regressed
1748
+ // when the context was registered unconditionally). Keep the exact baseline —
1749
+ // no contribution installed at all — until the Host-owned predicate matches.
1750
+ let cancelNoticeInstalled = false;
1751
+ const installUserCancelledToolNotice = () => {
1752
+ if (cancelNoticeInstalled)
1753
+ return;
1754
+ if (!userCancelledToolNotice(agent.session.snapshotEvents()))
1755
+ return;
1756
+ cancelNoticeInstalled = true;
1757
+ agentCtx.inject(['systemPrompt'], promptCtx => promptCtx.systemPrompt.context({
1758
+ name: 'assistant-delivery:user-cancelled-tool-notice',
1759
+ order: 260,
1760
+ text: () => userCancelledToolNotice(agent.session.snapshotEvents()),
1761
+ }));
1762
+ };
1763
+ // A cold resume may already carry the abort (the real Feishu sequence did).
1764
+ installUserCancelledToolNotice();
1765
+ // A live abort only becomes visible once its turn/end + tool/result land.
1766
+ agentCtx.on('session/event', (eventSession, event) => {
1767
+ if (eventSession !== agent.session)
1768
+ return;
1769
+ if (event.type === 'turn/end' || event.type === 'tool/result')
1770
+ installUserCancelledToolNotice();
1771
+ });
1671
1772
  await agentPresets?.mount(agentCtx, presetId);
1672
1773
  }
1673
1774
  /**
@@ -1858,8 +1959,8 @@ export class DshDeliveryRuntime {
1858
1959
  model: selected.model,
1859
1960
  maxTokens: this.options.maxOutputTokens,
1860
1961
  },
1861
- setup: async (agentCtx) => {
1862
- await this.setupAgent(agentCtx, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
1962
+ setup: async (agentCtx, preparedAgent) => {
1963
+ await this.setupAgent(agentCtx, preparedAgent, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
1863
1964
  },
1864
1965
  });
1865
1966
  signal.throwIfAborted();
@@ -2177,8 +2278,8 @@ export class DshDeliveryRuntime {
2177
2278
  await ensureWorkspace(workspace);
2178
2279
  const selected = agentSelection(toModelRoute(this.options.getModelSelection(input.envelope.conversation)
2179
2280
  ?? { provider: this.options.provider, model: this.options.model }));
2180
- const setup = async (agentCtx) => {
2181
- await this.setupAgent(agentCtx, workspace, presetId, externalPrincipalId(input.envelope.principal), selected, agentPresets);
2281
+ const setup = async (agentCtx, preparedAgent) => {
2282
+ await this.setupAgent(agentCtx, preparedAgent, workspace, presetId, externalPrincipalId(input.envelope.principal), selected, agentPresets);
2182
2283
  };
2183
2284
  const lease = this.sessionLeases.open({ kind: 'construction', sessionId: String(id),
2184
2285
  conversation: input.envelope.conversation, principal: input.envelope.principal,
@@ -2292,17 +2393,18 @@ export class DshDeliveryRuntime {
2292
2393
  resumeSessionId: SessionId(binding.sessionId),
2293
2394
  signal: control.controller.signal,
2294
2395
  agentOptions: { provider: selected.provider, model: selected.model, maxTokens: this.options.maxOutputTokens },
2295
- setup: async (agentCtx) => {
2396
+ setup: async (agentCtx, preparedAgent) => {
2296
2397
  // Restored inbox entries may wake immediately on Session publication.
2297
2398
  // Install the background-only gate before that publication boundary.
2298
- if (agentCtx.agent === undefined)
2399
+ const agent = preparedAgent ?? agentCtx.agent;
2400
+ if (agent === undefined)
2299
2401
  throw new Error('assistant-delivery: wake Agent missing');
2300
- releaseFences = this.installScheduledGoalFences(agentCtx.agent, input);
2301
- await this.setupAgent(agentCtx, binding.workspace, presetId, input.attestation.principalId, selected, presets, 'background');
2402
+ releaseFences = this.installScheduledGoalFences(agent, input);
2403
+ await this.setupAgent(agentCtx, agent, binding.workspace, presetId, input.attestation.principalId, selected, presets, 'background');
2302
2404
  const llm = this.ctx.get('llm');
2303
2405
  if (llm === undefined)
2304
2406
  throw new Error('assistant-delivery: llm service is required');
2305
- requireAdapterToolCallProtocol(llm, selected.provider, selected.model, presetId, agentCtx.tools.schemas(agentCtx.agent).length);
2407
+ requireAdapterToolCallProtocol(llm, selected.provider, selected.model, presetId, agentCtx.tools.schemas(preparedAgent ?? agentCtx.agent).length);
2306
2408
  },
2307
2409
  });
2308
2410
  const agent = handle.agent;
@@ -2414,6 +2516,39 @@ export class DshDeliveryRuntime {
2414
2516
  input.assertCurrent(agent, phase);
2415
2517
  }
2416
2518
  installScheduledGoalFences(agent, input) {
2519
+ let pendingGoalStep;
2520
+ const nativeGoalStep = (source) => {
2521
+ const native = this.ctx.get('goals')?.get(agent);
2522
+ const round = source.round;
2523
+ if (source.kind !== 'goal' || String(source.goalId) !== input.native.goalId
2524
+ || source.revision !== input.native.revision + 1 || typeof round !== 'number' || !Number.isSafeInteger(round) || round < 1
2525
+ || native === undefined || native.phase !== 'active' || String(native.id) !== input.native.goalId
2526
+ || native.revision !== source.revision || round !== native.roundsStarted + 1)
2527
+ return undefined;
2528
+ return { turn: 0, step: 0, goalId: String(source.goalId), revision: source.revision, round };
2529
+ };
2530
+ const durableTurnMessages = (turn) => {
2531
+ const events = agent.session.snapshotEvents();
2532
+ const start = events.findLast(event => event.type === 'turn/start');
2533
+ if (start?.type !== 'turn/start' || start.data.turn !== turn
2534
+ || events.some(event => event.seq > start.seq && event.type === 'turn/end')) {
2535
+ throw new Error('assistant-delivery: exact native goal turn required');
2536
+ }
2537
+ return events.filter(event => event.seq > start.seq && event.type === 'user/message'
2538
+ && !isScheduledGoalRuntimeContext(event.data.source));
2539
+ };
2540
+ const pendingCurrent = (turn, step) => {
2541
+ const pending = pendingGoalStep;
2542
+ if (pending === undefined || pending.turn !== turn || pending.step !== step)
2543
+ return undefined;
2544
+ const native = this.ctx.get('goals')?.get(agent);
2545
+ // Only bridge the gap before the accepted goal input is durably folded.
2546
+ // Once any input appears, the ordinary snapshot fence must verify it.
2547
+ if (native === undefined || native.phase !== 'active' || String(native.id) !== pending.goalId
2548
+ || native.revision !== pending.revision || native.roundsStarted !== pending.round - 1)
2549
+ return undefined;
2550
+ return pending;
2551
+ };
2417
2552
  const goalTurn = () => {
2418
2553
  const events = agent.session.snapshotEvents();
2419
2554
  const start = events.findLast(event => event.type === 'turn/start');
@@ -2439,23 +2574,57 @@ export class DshDeliveryRuntime {
2439
2574
  catch { }
2440
2575
  throw new Error('assistant-delivery: scheduled goal wake authorization changed');
2441
2576
  };
2442
- const request = agent.ctx.on('agent/request', async (_payload, next) => {
2443
- try {
2444
- goalTurn();
2577
+ const clearPending = () => { pendingGoalStep = undefined; };
2578
+ input.signal.addEventListener('abort', clearPending, { once: true });
2579
+ const preStep = agent.ctx.on('agent/pre-step', async (payload, next) => {
2580
+ // A pre-step decision is the sole new-Host bridge between the claimed
2581
+ // inbox item and its later durable append. Never carry it into another
2582
+ // boundary, rejection, replacement, or retry.
2583
+ clearPending();
2584
+ const offered = payload.messages.filter(message => !isScheduledGoalRuntimeContext(message.source));
2585
+ const offeredMessage = offered.length === 1 ? structuredClone(offered[0]) : undefined;
2586
+ const decision = await next();
2587
+ if (payload.signal.aborted || input.signal.aborted || decision.kind !== 'enter')
2588
+ return decision;
2589
+ const entered = decision.messages.filter(message => !isScheduledGoalRuntimeContext(message.source));
2590
+ if (offeredMessage === undefined || entered.length !== 1
2591
+ || typeof offeredMessage.id !== 'string' || offeredMessage.id.length === 0
2592
+ || !isDeepStrictEqual(entered[0], offeredMessage))
2593
+ return decision;
2594
+ const candidate = nativeGoalStep(entered[0].source);
2595
+ if (candidate === undefined)
2596
+ return decision;
2597
+ pendingGoalStep = { ...candidate, turn: payload.turn, step: payload.step };
2598
+ return decision;
2599
+ }, { prepend: true });
2600
+ const request = agent.ctx.on('agent/request', async (payload, next) => {
2601
+ const assertRequest = () => {
2602
+ payload.signal.throwIfAborted();
2603
+ if (durableTurnMessages(payload.turn).length > 0)
2604
+ goalTurn();
2605
+ else if (pendingCurrent(payload.turn, payload.step) === undefined)
2606
+ throw new Error('assistant-delivery: native goal step not admitted');
2445
2607
  this.assertScheduledGoal(input, agent, 'running');
2446
- }
2447
- catch {
2448
- return reject();
2449
- }
2450
- const result = await next();
2608
+ };
2451
2609
  try {
2452
- goalTurn();
2453
- this.assertScheduledGoal(input, agent, 'running');
2610
+ try {
2611
+ assertRequest();
2612
+ }
2613
+ catch {
2614
+ return reject();
2615
+ }
2616
+ const result = await next();
2617
+ try {
2618
+ assertRequest();
2619
+ }
2620
+ catch {
2621
+ return reject();
2622
+ }
2623
+ return result;
2454
2624
  }
2455
- catch {
2456
- return reject();
2625
+ finally {
2626
+ clearPending();
2457
2627
  }
2458
- return result;
2459
2628
  }, { prepend: true });
2460
2629
  const preExecute = agent.ctx.on('tools/pre-execute', async (_payload, next) => {
2461
2630
  try {
@@ -2485,7 +2654,7 @@ export class DshDeliveryRuntime {
2485
2654
  return 'assistant-delivery: scheduled goal wake authorization changed';
2486
2655
  }
2487
2656
  });
2488
- return () => { request(); preExecute(); guard(); };
2657
+ return () => { input.signal.removeEventListener('abort', clearPending); clearPending(); preStep(); request(); preExecute(); guard(); };
2489
2658
  }
2490
2659
  waitForScheduledGoalTerminal(agent, input, signal) {
2491
2660
  return new Promise(resolve => {
@@ -2988,8 +3157,8 @@ export class DshDeliveryRuntime {
2988
3157
  model: selected.model,
2989
3158
  maxTokens: this.options.maxOutputTokens,
2990
3159
  },
2991
- setup: async (agentCtx) => {
2992
- await this.setupAgent(agentCtx, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
3160
+ setup: async (agentCtx, preparedAgent) => {
3161
+ await this.setupAgent(agentCtx, preparedAgent, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
2993
3162
  },
2994
3163
  });
2995
3164
  resumed = true;
@@ -3301,6 +3470,8 @@ export class DshDeliveryRuntime {
3301
3470
  let handle;
3302
3471
  let dispatched = false;
3303
3472
  let acceptance;
3473
+ let foregroundExecution;
3474
+ let removeAcceptanceRequest;
3304
3475
  let acceptanceSucceeded = false;
3305
3476
  let goalContinuation;
3306
3477
  let goalContinuationQuiescent = true;
@@ -3329,12 +3500,12 @@ export class DshDeliveryRuntime {
3329
3500
  handle = await this.resumeAgent({ resumeSessionId: SessionId(binding.sessionId), signal,
3330
3501
  agentOptions: { provider: selected.provider, model: selected.model,
3331
3502
  maxTokens: this.options.maxOutputTokens },
3332
- setup: async (agentCtx) => {
3333
- await this.setupAgent(agentCtx, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
3503
+ setup: async (agentCtx, preparedAgent) => {
3504
+ await this.setupAgent(agentCtx, preparedAgent, binding.workspace, presetId, externalPrincipalId(binding.principal), selected, agentPresets);
3334
3505
  const llm = this.ctx.get('llm');
3335
3506
  if (llm === undefined)
3336
3507
  throw new Error('assistant-delivery: llm service is required');
3337
- requireAdapterToolCallProtocol(llm, selected.provider, selected.model, presetId, agentCtx.tools.schemas(agentCtx.agent).length);
3508
+ requireAdapterToolCallProtocol(llm, selected.provider, selected.model, presetId, agentCtx.tools.schemas(preparedAgent ?? agentCtx.agent).length);
3338
3509
  } });
3339
3510
  const agent = handle.agent;
3340
3511
  await this.reconcileNativeFullPermissionReviewer(agent.session);
@@ -3398,6 +3569,20 @@ export class DshDeliveryRuntime {
3398
3569
  // host driver is otherwise free to claim its first round at idle.
3399
3570
  goalContinuation = this.waitForNativeGoalContinuation(binding, envelope, agent, signal);
3400
3571
  acceptance = this.options.prepareForegroundTaskAcceptance(binding, envelope);
3572
+ foregroundExecution = this.options.beginForegroundTaskExecution(binding, envelope);
3573
+ if (acceptance !== undefined || foregroundExecution !== undefined)
3574
+ removeAcceptanceRequest = this.ctx.on('session/event', (session, event) => {
3575
+ if (session !== agent.session || event.type !== 'request/header')
3576
+ return;
3577
+ // `agent/request` exposes the candidate route. The native loop writes
3578
+ // this header only after `llm.prepareCall()` has applied adapter
3579
+ // defaults such as reasoning effort, which is the route actually sent
3580
+ // to the provider.
3581
+ if (foregroundExecution !== undefined)
3582
+ this.options.recordForegroundExecutionModelSelection(foregroundExecution, event.data.header.config);
3583
+ if (acceptance !== undefined)
3584
+ this.options.recordForegroundTaskModelSelection(acceptance, event.data.header.config);
3585
+ });
3401
3586
  markDispatching();
3402
3587
  // Once the durable marker exists, even a synchronous followup failure is ambiguous:
3403
3588
  // implementations may enqueue before throwing, so no retry is safe.
@@ -3561,18 +3746,28 @@ export class DshDeliveryRuntime {
3561
3746
  return { outcome: 'not-processed', failureCode: 'agent-turn-incomplete', retryable: false };
3562
3747
  }
3563
3748
  if (currentEmpty || assembledText.trim() === '') {
3564
- const hasPartial = bestEffortText.trim() !== '';
3565
- this.options.replyCommand(binding, envelope.eventId, {
3566
- text: hasPartial
3567
- ? truncatedAgentReply(bestEffortText, this.options.maxTextBytes)
3568
- : emptyAgentReply(this.options.maxTextBytes),
3569
- format: 'markdown',
3570
- });
3571
- publishProgress({
3572
- kind: 'failed',
3573
- code: hasPartial ? AGENT_OUTPUT_TRUNCATED_CODE : AGENT_EMPTY_RESPONSE_CODE,
3574
- });
3575
- return { outcome: 'processed' };
3749
+ // A tool-only foreground turn can arm a native goal (typically
3750
+ // `goal_create`); the host round driver then continues with its own
3751
+ // rounds and this turn has no user-facing reply text. It must not be
3752
+ // classified as an empty response, which would dispose the armed
3753
+ // continuation fence and cancel the in-flight goal round.
3754
+ if (output.hadToolCall && goalContinuation?.pinActiveArmedGoal()) {
3755
+ // Fall through to the continuation fence below instead of replying.
3756
+ }
3757
+ else {
3758
+ const hasPartial = bestEffortText.trim() !== '';
3759
+ this.options.replyCommand(binding, envelope.eventId, {
3760
+ text: hasPartial
3761
+ ? truncatedAgentReply(bestEffortText, this.options.maxTextBytes)
3762
+ : emptyAgentReply(this.options.maxTextBytes),
3763
+ format: 'markdown',
3764
+ });
3765
+ publishProgress({
3766
+ kind: 'failed',
3767
+ code: hasPartial ? AGENT_OUTPUT_TRUNCATED_CODE : AGENT_EMPTY_RESPONSE_CODE,
3768
+ });
3769
+ return { outcome: 'processed' };
3770
+ }
3576
3771
  }
3577
3772
  break;
3578
3773
  }
@@ -3580,10 +3775,12 @@ export class DshDeliveryRuntime {
3580
3775
  // The foreground reply has already crossed its durable reply path.
3581
3776
  // Retain the same Agent afterwards only for the native goal driver; its
3582
3777
  // unknown result must never turn the original reply into a claim that
3583
- // the business goal was achieved.
3584
- const learning = await this.options.replyCompletedPreferenceTurn(agent, binding, envelope, { text: assembledText, format: 'markdown' });
3585
- if (learning === 'unknown') {
3586
- this.ctx.logger.warn('assistant-delivery: completed-turn preference projection is ambiguous');
3778
+ // the business goal was achieved. A tool-only turn has no reply text.
3779
+ if (assembledText.trim() !== '') {
3780
+ const learning = await this.options.replyCompletedPreferenceTurn(agent, binding, envelope, { text: assembledText, format: 'markdown' });
3781
+ if (learning === 'unknown') {
3782
+ this.ctx.logger.warn('assistant-delivery: completed-turn preference projection is ambiguous');
3783
+ }
3587
3784
  }
3588
3785
  if (goalContinuation !== undefined) {
3589
3786
  goalContinuationQuiescent = await goalContinuation.settle();
@@ -3609,6 +3806,21 @@ export class DshDeliveryRuntime {
3609
3806
  if (causedByDurableIdentity(error)) {
3610
3807
  return { outcome: 'not-processed', failureCode: 'agent-identity-mismatch', retryable: false };
3611
3808
  }
3809
+ if (errorChainHasName(error, 'SessionFormatUnsupportedError')) {
3810
+ this.ctx.logger.warn(`assistant-delivery: session-format-unsupported for session ${sessionFingerprint(binding.sessionId)}`);
3811
+ try {
3812
+ this.options.replyCommand(binding, envelope.eventId, sessionFormatUnsupportedReply());
3813
+ // The durable reply is the only user effect. Dead-letter the original
3814
+ // inbound event so a legacy format cannot consume the normal retry
3815
+ // budget or produce a second notification.
3816
+ return { outcome: 'not-processed', failureCode: 'session-format-unsupported', retryable: false };
3817
+ }
3818
+ catch {
3819
+ // Do not mark a terminal diagnosis without its owner-authorized,
3820
+ // durable reply; Coordinator may retry this pre-dispatch failure.
3821
+ return { outcome: 'not-processed', failureCode: 'session-format-unsupported-notice-failed', retryable: true };
3822
+ }
3823
+ }
3612
3824
  if (error instanceof ImageCapabilityAdmissionError) {
3613
3825
  try {
3614
3826
  this.options.replyCommand(binding, envelope.eventId, imageCapabilityReply(error));
@@ -3632,6 +3844,7 @@ export class DshDeliveryRuntime {
3632
3844
  }
3633
3845
  finally {
3634
3846
  removeAbort?.();
3847
+ removeAcceptanceRequest?.();
3635
3848
  removeProgress?.();
3636
3849
  progressOpen = false;
3637
3850
  let disposed = false;
@@ -3641,6 +3854,16 @@ export class DshDeliveryRuntime {
3641
3854
  finally {
3642
3855
  acceptanceSucceeded = acceptanceSucceeded && disposed && !signal.aborted
3643
3856
  && (goalContinuation?.isQuiescent() ?? true);
3857
+ if (foregroundExecution !== undefined) {
3858
+ try {
3859
+ this.options.completeForegroundTaskExecution(foregroundExecution, {
3860
+ status: acceptanceSucceeded ? 'succeeded' : 'unknown', quiescent: acceptanceSucceeded,
3861
+ });
3862
+ }
3863
+ catch (error) {
3864
+ this.ctx.logger.warn(`assistant-delivery: foreground execution completion failed: ${String(error)}`);
3865
+ }
3866
+ }
3644
3867
  if (acceptance !== undefined) {
3645
3868
  try {
3646
3869
  await this.options.completeForegroundTaskAcceptance(acceptance, {