@borgee/agents-host 0.2.62 → 0.2.65

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.
@@ -1,6 +1,6 @@
1
1
  import type { ChatControlPlane } from './chat/chat-control-plane.js';
2
2
  import { type AgentsHostRuntimeOptions } from './debug.js';
3
- import type { ProviderAdapter } from './providers/provider-adapter.js';
3
+ import { type ProviderAdapter } from './providers/provider-adapter.js';
4
4
  import type { AgentsHostConfig } from './types.js';
5
5
  /**
6
6
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
@@ -160,6 +160,8 @@ export declare class AgentsHost {
160
160
  private readCollaborationDraft;
161
161
  private supersedeActiveTurn;
162
162
  private invalidateChannelTurn;
163
+ private stopTurn;
164
+ private responseStateForExecuted;
163
165
  private rememberRecentTurn;
164
166
  private resolveAuthorizedCollaborationTurn;
165
167
  private updateAwaitingUserState;
@@ -12,6 +12,7 @@ import { createLocalhostGatewayController, LOCALHOST_GATEWAY_COLLABORATION_TARGE
12
12
  import { extractTaskIdFromTaskAssignmentContent, resolveTaskAssignmentStatePath, } from './context/injection.js';
13
13
  import { AuthorizationAuditSink, } from './policy/authorization-audit.js';
14
14
  import { createProvider } from './providers/create-provider.js';
15
+ import { ProviderTurnCancelledError } from './providers/provider-adapter.js';
15
16
  import { resolveAttentionStatePath, resolveCompactionStatePath, resolveSharedProtocolKickoffDecisionPath, resolveSharedProtocolStatusPath, } from './state-paths.js';
16
17
  import { appendVisibleMention as appendVisibleBodyMention, extractVisibleMentionIds as extractCanonicalMentionIds, } from './visible-mentions.js';
17
18
  import { buildHostedIncomingContentText, buildHostedTurnContentParts, extractHostedMessageAttachments, normalizeHostedMessageAttachment, } from './hosted-turn-content.js';
@@ -24,6 +25,9 @@ const PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS = 50;
24
25
  const PROTOCOL_KICKOFF_ROUND_LIMIT = 20;
25
26
  const COLLABORATION_LATE_SEND_GRACE_MS = 5_000;
26
27
  const ORDINARY_HUMAN_EVENT_KINDS = new Set(['', 'message']);
28
+ const TASK_EXECUTION_LOCAL_DIRECTORY_PROPERTY_KEY = 'execution.local_directory';
29
+ const TASK_EXECUTION_LOCAL_DIRECTORY_REMINDER = 'This task has no Execution Local Directory yet. Set it before asking me to work in a real local project directory.';
30
+ const TASK_EXECUTION_LOCAL_DIRECTORY_IN_USE_PREFIX = 'Using Execution Local Directory:';
27
31
  async function ensurePrivateStateRoot(path) {
28
32
  await mkdir(path, { recursive: true, mode: PRIVATE_STATE_ROOT_MODE });
29
33
  await chmod(path, PRIVATE_STATE_ROOT_MODE);
@@ -145,13 +149,15 @@ class DefaultHostRuntime {
145
149
  });
146
150
  this.ensureStateRoot = deps.ensureStateRoot ?? ensurePrivateStateRoot;
147
151
  }
148
- async start(onMessage) {
152
+ async start(onMessage, onStopTurn) {
149
153
  await this.ensureStateRoot(this.config.stateRootDir);
150
154
  await this.gateway.start();
151
155
  try {
156
+ this.controlPlane.onStopTurn?.(onStopTurn);
152
157
  await this.controlPlane.connect(onMessage);
153
158
  }
154
159
  catch (error) {
160
+ this.controlPlane.onStopTurn?.(undefined);
155
161
  await this.gateway.stop().catch(() => { });
156
162
  throw error;
157
163
  }
@@ -159,6 +165,7 @@ class DefaultHostRuntime {
159
165
  async stop() {
160
166
  let thrown;
161
167
  try {
168
+ this.controlPlane.onStopTurn?.(undefined);
162
169
  await this.controlPlane.close();
163
170
  }
164
171
  catch (error) {
@@ -366,7 +373,7 @@ export class AgentsHost {
366
373
  return;
367
374
  }
368
375
  this.dispatchMessage(message);
369
- });
376
+ }, async (channelId) => this.stopTurn(channelId));
370
377
  const agentId = await this.ensureSelfAgentId();
371
378
  bufferingMessages = false;
372
379
  while (bufferedMessages.length > 0) {
@@ -675,7 +682,7 @@ export class AgentsHost {
675
682
  return null;
676
683
  }
677
684
  this.recordCollaborationOutcome(msg.channel_id, {
678
- responseState: executed.completed ? 'responded' : 'failed',
685
+ responseState: this.responseStateForExecuted(executed),
679
686
  deliveryState: executed.deliveryState ?? 'not-delivered',
680
687
  });
681
688
  this.updateMissedCollaborationDiagnostic(msg.channel_id, priorMissedCollaborationDiagnostic);
@@ -1110,7 +1117,7 @@ export class AgentsHost {
1110
1117
  channelState.blocked = undefined;
1111
1118
  this.awaitingUserByChannel.delete(channelId);
1112
1119
  this.recordCollaborationOutcome(channelId, {
1113
- responseState: executed.completed ? 'responded' : 'failed',
1120
+ responseState: this.responseStateForExecuted(executed),
1114
1121
  deliveryState: executed.deliveryState ?? 'not-delivered',
1115
1122
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1116
1123
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1174,7 +1181,7 @@ export class AgentsHost {
1174
1181
  const visibleMessageId = deliveredMessageId ?? protocolState.lastProtocolMessageId;
1175
1182
  if (executed.reply?.controlMalformed) {
1176
1183
  this.recordCollaborationOutcome(channelId, {
1177
- responseState: executed.completed ? 'responded' : 'failed',
1184
+ responseState: this.responseStateForExecuted(executed),
1178
1185
  deliveryState: executed.deliveryState ?? 'not-delivered',
1179
1186
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1180
1187
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1184,7 +1191,7 @@ export class AgentsHost {
1184
1191
  }
1185
1192
  if (!executed.reply?.control) {
1186
1193
  this.recordCollaborationOutcome(channelId, {
1187
- responseState: executed.completed ? 'responded' : 'failed',
1194
+ responseState: this.responseStateForExecuted(executed),
1188
1195
  deliveryState: executed.deliveryState ?? 'not-delivered',
1189
1196
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1190
1197
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1218,7 +1225,7 @@ export class AgentsHost {
1218
1225
  }
1219
1226
  if (executed.reply.control.kind === 'start-protocol') {
1220
1227
  this.recordCollaborationOutcome(channelId, {
1221
- responseState: executed.completed ? 'responded' : 'failed',
1228
+ responseState: this.responseStateForExecuted(executed),
1222
1229
  deliveryState: executed.deliveryState ?? 'not-delivered',
1223
1230
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1224
1231
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -1229,7 +1236,7 @@ export class AgentsHost {
1229
1236
  }
1230
1237
  if (executed.reply.control.kind === 'attention-only') {
1231
1238
  this.recordCollaborationOutcome(channelId, {
1232
- responseState: executed.completed ? 'responded' : 'failed',
1239
+ responseState: this.responseStateForExecuted(executed),
1233
1240
  deliveryState: executed.deliveryState ?? 'not-delivered',
1234
1241
  ...(message.wakeBlockedState ? { wakeState: 'resumed-human' } : {}),
1235
1242
  turnExecutionId: activeTurn.turnExecutionId ?? undefined,
@@ -2188,9 +2195,7 @@ export class AgentsHost {
2188
2195
  this.recordCollaborationOutcome(msg.channel_id, {
2189
2196
  responseState: executed.completed && executed.reply?.awaitingUser
2190
2197
  ? 'blocked'
2191
- : executed.completed
2192
- ? 'responded'
2193
- : 'failed',
2198
+ : this.responseStateForExecuted(executed),
2194
2199
  deliveryState: executed.deliveryState ?? 'not-delivered',
2195
2200
  ...(executed.completed && executed.reply?.awaitingUser
2196
2201
  ? {
@@ -2444,6 +2449,65 @@ export class AgentsHost {
2444
2449
  turnExecutionId: activeTurn.turnExecutionId,
2445
2450
  });
2446
2451
  }
2452
+ if (error instanceof ProviderTurnCancelledError) {
2453
+ const cancelledReplyText = error.publicReplyText?.trim();
2454
+ if (!silentTurn &&
2455
+ !deferDelivery &&
2456
+ typeof cancelledReplyText === 'string' &&
2457
+ cancelledReplyText.length > 0 &&
2458
+ this.started &&
2459
+ !this.controlPlaneClosed) {
2460
+ reply = { text: cancelledReplyText };
2461
+ publicReplyText = cancelledReplyText;
2462
+ visibleReplyBody = this.resolveVisibleTurnBody(activeTurn, reply, publicReplyText);
2463
+ try {
2464
+ const posted = await this.borgee.postMessage({
2465
+ channelId,
2466
+ body: visibleReplyBody,
2467
+ replyToId: activeTurn?.delivery?.replyToId,
2468
+ });
2469
+ this.logger.debug('posted cancelled turn feedback', {
2470
+ ...turnContext,
2471
+ durationMs: Date.now() - turnStartedAt,
2472
+ replyLength: publicReplyText.length,
2473
+ delivery: 'post',
2474
+ });
2475
+ return {
2476
+ reply,
2477
+ deliveredMessageId: posted.messageId,
2478
+ publicReplyText,
2479
+ visibleReplyBody,
2480
+ deliveryState: 'posted',
2481
+ stale: activeTurn?.superseded === true,
2482
+ completed: false,
2483
+ cancelled: true,
2484
+ };
2485
+ }
2486
+ catch (postError) {
2487
+ this.logger.debugError('failed to post cancelled turn feedback', {
2488
+ ...turnContext,
2489
+ error: postError,
2490
+ });
2491
+ this.logger.error('failed to post cancelled turn feedback', {
2492
+ ...turnContext,
2493
+ error: summarizeError(postError),
2494
+ });
2495
+ }
2496
+ }
2497
+ this.logger.debug('cancelled turn', {
2498
+ ...turnContext,
2499
+ durationMs: Date.now() - turnStartedAt,
2500
+ });
2501
+ return {
2502
+ reply,
2503
+ publicReplyText,
2504
+ visibleReplyBody,
2505
+ deliveryState: 'not-delivered',
2506
+ stale: activeTurn?.superseded === true,
2507
+ completed: false,
2508
+ cancelled: true,
2509
+ };
2510
+ }
2447
2511
  this.logger.debugError('failed to generate or send reply', {
2448
2512
  ...turnContext,
2449
2513
  error,
@@ -2655,6 +2719,29 @@ export class AgentsHost {
2655
2719
  activeTurn.turnExecutionId = null;
2656
2720
  }
2657
2721
  }
2722
+ async stopTurn(channelId) {
2723
+ const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
2724
+ const aborted = await this.provider.cancelTurn?.(channelId) ?? false;
2725
+ if (!aborted) {
2726
+ return { aborted: false };
2727
+ }
2728
+ if (activeTurn) {
2729
+ this.supersedeActiveTurn(channelId, activeTurn);
2730
+ }
2731
+ else {
2732
+ this.recordCollaborationOutcome(channelId, {
2733
+ responseState: 'superseded',
2734
+ deliveryState: 'not-delivered',
2735
+ });
2736
+ }
2737
+ return { aborted: true };
2738
+ }
2739
+ responseStateForExecuted(executed) {
2740
+ if (executed.cancelled) {
2741
+ return 'superseded';
2742
+ }
2743
+ return executed.completed ? 'responded' : 'failed';
2744
+ }
2658
2745
  rememberRecentTurn(channelState, activeTurn) {
2659
2746
  if (!activeTurn.turnExecutionId) {
2660
2747
  channelState.recentTurn = undefined;
@@ -2853,6 +2940,34 @@ export class AgentsHost {
2853
2940
  task = null;
2854
2941
  }
2855
2942
  if (task && task.assigneeId === selfAgentId) {
2943
+ const executionLocalDirectory = task.properties?.[TASK_EXECUTION_LOCAL_DIRECTORY_PROPERTY_KEY];
2944
+ if (typeof executionLocalDirectory !== 'string' || executionLocalDirectory.trim().length === 0) {
2945
+ try {
2946
+ await this.borgee.postMessage({
2947
+ channelId: msg.channel_id,
2948
+ body: TASK_EXECUTION_LOCAL_DIRECTORY_REMINDER,
2949
+ });
2950
+ }
2951
+ catch (error) {
2952
+ this.logger.debugError('task local directory reminder post failed', {
2953
+ channelId: msg.channel_id,
2954
+ error,
2955
+ });
2956
+ }
2957
+ return null;
2958
+ }
2959
+ try {
2960
+ await this.borgee.postMessage({
2961
+ channelId: msg.channel_id,
2962
+ body: `${TASK_EXECUTION_LOCAL_DIRECTORY_IN_USE_PREFIX} ${executionLocalDirectory}`,
2963
+ });
2964
+ }
2965
+ catch (error) {
2966
+ this.logger.debugError('task local directory notice post failed', {
2967
+ channelId: msg.channel_id,
2968
+ error,
2969
+ });
2970
+ }
2856
2971
  if (task.status === 'open') {
2857
2972
  try {
2858
2973
  await this.borgee.updateTask({
@@ -1,6 +1,7 @@
1
- import type { ReportTurnActivityInput, TurnActivityReporter } from '../plugin-sdk.js';
1
+ import type { ReportTurnActivityInput, StopTurnHandler, TurnActivityReporter } from '../plugin-sdk.js';
2
2
  import type { ChannelSummary, ChannelHistoryEntry, ChannelMessageEvent, CreateTaskInput, DirectoryUser, MeResponseUser, PostMessageInput, PostedMessage, ReadChannelHistoryInput, Task, UpdateTaskInput } from '../types.js';
3
3
  export interface ChatControlPlane {
4
+ onStopTurn?(handler: StopTurnHandler | undefined): void;
4
5
  connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
5
6
  close(): Promise<void>;
6
7
  postMessage(input: PostMessageInput): Promise<PostedMessage>;
@@ -1,7 +1,7 @@
1
- import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent, type ReportTurnActivityInput, type TurnActivityReporter } from '../plugin-sdk.js';
1
+ import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent, type ReportTurnActivityInput, type StopTurnHandler, type TurnActivityReporter } from '../plugin-sdk.js';
2
2
  import type { ChannelSummary, ChannelHistoryEntry, ChannelMessageEvent, CreateTaskInput, DirectoryUser, MeResponseUser, PostMessageInput, PostedMessage, ReadChannelHistoryInput, Task, UpdateTaskInput } from '../types.js';
3
3
  import type { ChatControlPlane } from './chat-control-plane.js';
4
- type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'readHistory' | 'reportTurnActivity' | 'sendMessage' | 'startTyping' | 'updateTask' | 'setTaskProperty' | 'deleteTaskProperty'>;
4
+ type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'onStopTurn' | 'readHistory' | 'reportTurnActivity' | 'sendMessage' | 'startTyping' | 'updateTask' | 'setTaskProperty' | 'deleteTaskProperty'>;
5
5
  type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
6
  type SdkChatControlPlaneOptions = Pick<BorgeePluginOptions, 'pluginId'>;
7
7
  /**
@@ -16,6 +16,7 @@ export declare class SdkChatControlPlane implements ChatControlPlane {
16
16
  private connected;
17
17
  private me;
18
18
  constructor(baseUrl: string, apiKey: string, createClient?: PluginClientFactory, options?: SdkChatControlPlaneOptions);
19
+ onStopTurn(handler: StopTurnHandler | undefined): void;
19
20
  connect(onMessage: (message: ChannelMessageEvent) => void): Promise<void>;
20
21
  close(): Promise<void>;
21
22
  postMessage(input: PostMessageInput): Promise<PostedMessage>;
@@ -14,6 +14,9 @@ export class SdkChatControlPlane {
14
14
  constructor(baseUrl, apiKey, createClient = createBorgeePlugin, options = {}) {
15
15
  this.client = createClient({ baseUrl, apiKey, ...options });
16
16
  }
17
+ onStopTurn(handler) {
18
+ this.client.onStopTurn(handler);
19
+ }
17
20
  async connect(onMessage) {
18
21
  this.unsubscribe = this.client.on('message', (event) => {
19
22
  const message = mapInboundToChannelMessage(event);
@@ -46,6 +49,7 @@ export class SdkChatControlPlane {
46
49
  this.pendingMessages = [];
47
50
  this.unsubscribe?.();
48
51
  this.unsubscribe = null;
52
+ this.client.onStopTurn(undefined);
49
53
  await this.client.close();
50
54
  }
51
55
  async postMessage(input) {
@@ -9,5 +9,6 @@ export declare class ClaudeProviderAdapter implements ProviderAdapter {
9
9
  readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
10
10
  constructor(cli: ClaudeCliClient, turnPreparer: ProviderTurnPreparer);
11
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
12
+ cancelTurn(channelId: string): Promise<boolean>;
12
13
  dispose(): Promise<void>;
13
14
  }
@@ -25,6 +25,9 @@ export class ClaudeProviderAdapter {
25
25
  // session as it runs, so before this point there is nothing to report.
26
26
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
27
27
  }
28
+ async cancelTurn(channelId) {
29
+ return this.cli.cancelTurn(channelId);
30
+ }
28
31
  async dispose() {
29
32
  await this.cli.dispose();
30
33
  }
@@ -43,6 +43,7 @@ export declare class ClaudeCliClient {
43
43
  * worked a task without the agent being asked to report its own id.
44
44
  */
45
45
  sessionIdForChannel(channelId: string): string | undefined;
46
+ cancelTurn(channelId: string): Promise<boolean>;
46
47
  private readonly persistedSessions;
47
48
  private readonly closingSessions;
48
49
  private readonly pendingSessionStarts;
@@ -9,6 +9,7 @@ import { assertClaudeCommandCompatibility, DEFAULT_CLAUDE_COMMAND, isLegacyClaud
9
9
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
10
10
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
11
11
  import { AcpProgressCollector } from '../acp-progress-collector.js';
12
+ import { ProviderTurnCancelledError } from '../provider-adapter.js';
12
13
  import { readClaudeActivityMetadata } from './activity-metadata.js';
13
14
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
14
15
  import { buildClaudeSessionPromptAppend } from '../../context/prompt.js';
@@ -32,6 +33,7 @@ const DEFAULT_SESSION_CAPABILITIES = {
32
33
  };
33
34
  const CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD_ENV = 'CLAUDE_SDK_COMPACTION_EXPERIMENT_CONTEXT_TOKEN_THRESHOLD';
34
35
  const require = createRequire(import.meta.url);
36
+ const CLAUDE_CANCELLED_REPLY_TEXT = 'Info: Operation cancelled by user';
35
37
  const DEFAULT_RUNTIME = {
36
38
  spawn,
37
39
  client,
@@ -495,6 +497,22 @@ export class ClaudeCliClient {
495
497
  sessionIdForChannel(channelId) {
496
498
  return this.channels.get(channelId)?.session?.sessionId;
497
499
  }
500
+ async cancelTurn(channelId) {
501
+ const state = this.channels.get(channelId);
502
+ const session = state?.session;
503
+ if (!state?.activeTurn || !session) {
504
+ return false;
505
+ }
506
+ this.idleBackendShutdown.cancel();
507
+ const activeSession = session;
508
+ if (typeof activeSession.cancel === 'function') {
509
+ await activeSession.cancel({ sessionId: session.sessionId });
510
+ return true;
511
+ }
512
+ const cancelMethod = this.runtime.methods.agent.session?.cancel ?? 'session/cancel';
513
+ await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
514
+ return true;
515
+ }
498
516
  persistedSessions = new Map();
499
517
  closingSessions = new WeakSet();
500
518
  pendingSessionStarts = new Set();
@@ -975,6 +993,11 @@ export class ClaudeCliClient {
975
993
  throw markSessionTainted(error);
976
994
  }
977
995
  const output = collector.getFinalText();
996
+ if (response.stopReason === 'cancelled') {
997
+ throw new ProviderTurnCancelledError('Claude ACP turn cancelled', {
998
+ publicReplyText: hasVisibleText(output) ? output : CLAUDE_CANCELLED_REPLY_TEXT,
999
+ });
1000
+ }
978
1001
  if (response.stopReason !== 'end_turn' && !hasVisibleText(output)) {
979
1002
  throw new Error(`Claude ACP turn stopped with stopReason "${response.stopReason}"`);
980
1003
  }
@@ -9,5 +9,6 @@ export declare class CodexProviderAdapter implements ProviderAdapter {
9
9
  readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
10
10
  constructor(cli: CodexCliClient, turnPreparer: ProviderTurnPreparer);
11
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
12
+ cancelTurn(channelId: string): Promise<boolean>;
12
13
  dispose(): Promise<void>;
13
14
  }
@@ -25,6 +25,9 @@ export class CodexProviderAdapter {
25
25
  // session as it runs, so before this point there is nothing to report.
26
26
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
27
27
  }
28
+ async cancelTurn(channelId) {
29
+ return this.cli.cancelTurn(channelId);
30
+ }
28
31
  async dispose() {
29
32
  await this.cli.dispose();
30
33
  }
@@ -49,6 +49,7 @@ export declare class CodexCliClient {
49
49
  * worked a task without the agent being asked to report its own id.
50
50
  */
51
51
  sessionIdForChannel(channelId: string): string | undefined;
52
+ cancelTurn(channelId: string): Promise<boolean>;
52
53
  private readonly persistedSessions;
53
54
  private readonly closingSessions;
54
55
  private readonly pendingSessionStarts;
@@ -6,6 +6,7 @@ import spawn from 'cross-spawn';
6
6
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
7
7
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
8
8
  import { AcpProgressCollector } from '../acp-progress-collector.js';
9
+ import { ProviderTurnCancelledError } from '../provider-adapter.js';
9
10
  import { assertCodexProjectDocumentSize, buildCodexProjectDocument } from './project-doc.js';
10
11
  import { isGatewayCredentialSidecarBasename } from '../../context/injection.js';
11
12
  import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
@@ -34,6 +35,7 @@ const CODEX_CONTEXT_ROOT_DIRNAME = 'codex-context';
34
35
  const PROJECTION_UNAVAILABLE_PLACEHOLDER = '[codex-projection-unavailable]';
35
36
  const QUEUED_TURN_DROPPED_MESSAGE = 'Codex ACP session was reset after a failed turn; queued turns were dropped instead of replaying them on a fresh session';
36
37
  const require = createRequire(import.meta.url);
38
+ const CODEX_CANCELLED_REPLY_TEXT = 'Info: Operation cancelled by user';
37
39
  const DEFAULT_RUNTIME = {
38
40
  spawn,
39
41
  client,
@@ -230,6 +232,23 @@ export class CodexCliClient {
230
232
  sessionIdForChannel(channelId) {
231
233
  return this.channels.get(channelId)?.session?.sessionId;
232
234
  }
235
+ async cancelTurn(channelId) {
236
+ const state = this.channels.get(channelId);
237
+ const session = state?.session;
238
+ if (!state?.activeTurn || !session) {
239
+ return false;
240
+ }
241
+ this.clearIdleTimer(state);
242
+ this.idleBackendShutdown.cancel();
243
+ const activeSession = session;
244
+ if (typeof activeSession.cancel === 'function') {
245
+ await activeSession.cancel({ sessionId: session.sessionId });
246
+ return true;
247
+ }
248
+ const cancelMethod = this.runtime.methods.agent.session?.cancel ?? 'session/cancel';
249
+ await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
250
+ return true;
251
+ }
233
252
  persistedSessions = new Map();
234
253
  closingSessions = new WeakSet();
235
254
  pendingSessionStarts = new Set();
@@ -831,10 +850,15 @@ export class CodexCliClient {
831
850
  catch (error) {
832
851
  throw markSessionTainted(error);
833
852
  }
853
+ const output = collector.getFinalText();
854
+ if (response.stopReason === 'cancelled') {
855
+ throw new ProviderTurnCancelledError('Codex ACP turn cancelled', {
856
+ publicReplyText: output || CODEX_CANCELLED_REPLY_TEXT,
857
+ });
858
+ }
834
859
  if (response.stopReason !== 'end_turn') {
835
860
  throw new Error(`Codex ACP turn stopped with stopReason "${response.stopReason}"`);
836
861
  }
837
- const output = collector.getFinalText();
838
862
  if (!output) {
839
863
  throw new Error('Codex ACP returned empty output');
840
864
  }
@@ -9,5 +9,6 @@ export declare class CopilotProviderAdapter implements ProviderAdapter {
9
9
  readonly capabilities: import("../provider-adapter.js").ProviderCapabilities;
10
10
  constructor(cli: CopilotCliClient, turnPreparer: ProviderTurnPreparer);
11
11
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
12
+ cancelTurn(channelId: string): Promise<boolean>;
12
13
  dispose(): Promise<void>;
13
14
  }
@@ -25,6 +25,9 @@ export class CopilotProviderAdapter {
25
25
  // session as it runs, so before this point there is nothing to report.
26
26
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.channelId) };
27
27
  }
28
+ async cancelTurn(channelId) {
29
+ return this.cli.cancelTurn(channelId);
30
+ }
28
31
  async dispose() {
29
32
  await this.cli.dispose();
30
33
  }
@@ -51,6 +51,7 @@ export declare class CopilotCliClient {
51
51
  * worked a task without the agent being asked to report its own id.
52
52
  */
53
53
  sessionIdForChannel(channelId: string): string | undefined;
54
+ cancelTurn(channelId: string): Promise<boolean>;
54
55
  private readonly persistedSessions;
55
56
  private readonly closingSessions;
56
57
  private readonly pendingSessionStarts;
@@ -3,6 +3,7 @@ import spawn from 'cross-spawn';
3
3
  import { PROTOCOL_VERSION, client, methods, ndJsonStream, } from '@agentclientprotocol/sdk';
4
4
  import { HostLogger, summarizeChildStderr, summarizeError } from '../../debug.js';
5
5
  import { AcpProgressCollector } from '../acp-progress-collector.js';
6
+ import { ProviderTurnCancelledError } from '../provider-adapter.js';
6
7
  import { resolveCopilotPermissionResponse } from '../../policy/copilot-permission.js';
7
8
  import { isDiscussionOnlyResolvedWorkspace } from '../../context/resolved-workspace.js';
8
9
  import { IDLE_BACKEND_SHUTDOWN_DISABLED_MS, IdleBackendShutdownScheduler, } from '../idle-backend-shutdown.js';
@@ -170,6 +171,23 @@ export class CopilotCliClient {
170
171
  sessionIdForChannel(channelId) {
171
172
  return this.channels.get(channelId)?.session?.sessionId;
172
173
  }
174
+ async cancelTurn(channelId) {
175
+ const state = this.channels.get(channelId);
176
+ const session = state?.session;
177
+ if (!state?.activeTurn || !session) {
178
+ return false;
179
+ }
180
+ this.clearIdleTimer(state);
181
+ this.idleBackendShutdown.cancel();
182
+ const activeSession = session;
183
+ if (typeof activeSession.cancel === 'function') {
184
+ await activeSession.cancel({ sessionId: session.sessionId });
185
+ return true;
186
+ }
187
+ const cancelMethod = this.runtime.methods.agent.session?.cancel ?? 'session/cancel';
188
+ await this.connection?.agent.notify(cancelMethod, { sessionId: session.sessionId });
189
+ return true;
190
+ }
173
191
  persistedSessions = new Map();
174
192
  closingSessions = new WeakSet();
175
193
  pendingSessionStarts = new Set();
@@ -651,6 +669,9 @@ export class CopilotCliClient {
651
669
  catch (error) {
652
670
  throw markSessionTainted(error);
653
671
  }
672
+ if (response.stopReason === 'cancelled') {
673
+ throw new ProviderTurnCancelledError('Copilot ACP turn cancelled');
674
+ }
654
675
  if (response.stopReason !== 'end_turn') {
655
676
  throw new Error(`Copilot ACP turn stopped with stopReason "${response.stopReason}"`);
656
677
  }
@@ -30,6 +30,9 @@ class ProviderV2CompatibilityAdapter {
30
30
  turn: preparedTurn,
31
31
  }, options);
32
32
  }
33
+ async cancelTurn(channelId) {
34
+ return this.provider.cancelTurn?.(channelId) ?? false;
35
+ }
33
36
  async dispose() {
34
37
  await this.provider.shutdown?.();
35
38
  }
@@ -47,6 +50,9 @@ class CliBackedProviderV2 {
47
50
  // session as it runs, so before this point there is nothing to report.
48
51
  return { ...parseProviderReply(text), sessionId: this.cli.sessionIdForChannel(input.turn.channelId) };
49
52
  }
53
+ async cancelTurn(channelId) {
54
+ return this.cli.cancelTurn(channelId);
55
+ }
50
56
  async shutdown() {
51
57
  await this.cli.dispose();
52
58
  }
@@ -33,8 +33,15 @@ export declare function createHostedProviderCapabilities(params: {
33
33
  sharedHostFallback?: SharedHostAttachmentMetadataFallbackCapability;
34
34
  }): ProviderCapabilities;
35
35
  export declare const TEXT_ONLY_HOSTED_PROVIDER_CAPABILITIES: ProviderCapabilities;
36
+ export declare class ProviderTurnCancelledError extends Error {
37
+ readonly publicReplyText?: string;
38
+ constructor(message?: string, options?: {
39
+ publicReplyText?: string;
40
+ });
41
+ }
36
42
  export interface ProviderAdapter {
37
43
  readonly capabilities: ProviderCapabilities;
38
44
  generateReply(input: ProviderInput, options?: ProviderGenerateOptions): Promise<ProviderReply>;
45
+ cancelTurn?(channelId: string): Promise<boolean>;
39
46
  dispose?(): Promise<void>;
40
47
  }
@@ -42,3 +42,11 @@ export const TEXT_ONLY_HOSTED_PROVIDER_CAPABILITIES = createHostedProviderCapabi
42
42
  delivery: [],
43
43
  },
44
44
  });
45
+ export class ProviderTurnCancelledError extends Error {
46
+ publicReplyText;
47
+ constructor(message = 'provider turn cancelled', options) {
48
+ super(message);
49
+ this.name = 'ProviderTurnCancelledError';
50
+ this.publicReplyText = options?.publicReplyText;
51
+ }
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@borgee/agents-host",
3
- "version": "0.2.62",
3
+ "version": "0.2.65",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"