@ai-sdk/harness-acp 1.0.0 → 1.0.2

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,4 +1,5 @@
1
1
  import type { HarnessV1PermissionMode } from '@ai-sdk/harness';
2
+ import type { ACPToolCall } from '../acp-tool-call';
2
3
 
3
4
  export type ACPSerializablePrimitive = string | number | boolean | null;
4
5
 
@@ -26,33 +27,24 @@ export type ACPProfileValue =
26
27
  | ReadonlyArray<ACPProfileValue>
27
28
  | { readonly [key: string]: ACPProfileValue };
28
29
 
29
- type ACPNpmLaunchSettings = {
30
- readonly type: 'npm';
31
- readonly executable: string;
32
- readonly args?: ReadonlyArray<string>;
33
- readonly forwardEnv?: ReadonlyArray<string>;
30
+ export type ACPNpmSimpleSource = {
31
+ readonly type: 'npm-simple';
32
+ readonly packageName: string;
34
33
  /**
35
- * Runtime environment values that are safe to persist in bootstrap and
36
- * lifecycle compatibility identity.
34
+ * Exact version to install. When omitted, the package's `latest` dist-tag is
35
+ * installed and no version takes part in the implementation identity, so a
36
+ * new upstream release does not invalidate existing lifecycle state.
37
37
  */
38
- readonly env?: Readonly<Record<string, string>>;
39
- };
40
-
41
- export type ACPSimpleNpmImplementation = ACPNpmLaunchSettings & {
42
- readonly mode: 'simple';
43
- readonly packageName: string;
44
- readonly version: string;
38
+ readonly packageVersion?: string;
45
39
  };
46
40
 
47
- export type ACPLockedNpmImplementation = ACPNpmLaunchSettings & {
48
- readonly mode: 'locked';
41
+ export type ACPNpmLockedSource = {
42
+ readonly type: 'npm-locked';
49
43
  readonly packageJson: string;
50
44
  readonly pnpmLockYaml: string;
51
45
  };
52
46
 
53
- export type ACPNpmImplementation =
54
- | ACPSimpleNpmImplementation
55
- | ACPLockedNpmImplementation;
47
+ export type ACPSource = ACPNpmSimpleSource | ACPNpmLockedSource;
56
48
 
57
49
  export type ACPAuthentication = {
58
50
  readonly methodId: string;
@@ -86,8 +78,18 @@ export type ACPPermissionModeMapping = Readonly<
86
78
  export type ACPV1Settings = {
87
79
  readonly version?: 'v1';
88
80
  readonly harnessId: string;
81
+ readonly mcpServers?: Record<string, unknown>;
82
+ readonly isMcpToolCall?: (toolCall: ACPToolCall) => boolean;
89
83
  readonly auth?: ACPProviderAuthenticationMode;
90
- readonly implementation: ACPNpmImplementation;
84
+ readonly source: ACPSource;
85
+ readonly executable: string;
86
+ readonly args?: ReadonlyArray<string>;
87
+ readonly forwardEnv?: ReadonlyArray<string>;
88
+ /**
89
+ * Runtime environment values that are safe to persist in bootstrap and
90
+ * lifecycle compatibility identity.
91
+ */
92
+ readonly env?: Readonly<Record<string, string>>;
91
93
  readonly authentication?: ACPAuthentication;
92
94
  readonly providerAuthentication?: ACPProviderAuthentication;
93
95
  readonly modelId?: string;
@@ -95,4 +97,9 @@ export type ACPV1Settings = {
95
97
  readonly session?: {
96
98
  readonly meta?: Readonly<Record<string, ACPSerializableValue>>;
97
99
  };
100
+ /**
101
+ * Creates the authentication token used by the sandbox bridge. Defaults to
102
+ * a random 32-byte hexadecimal token.
103
+ */
104
+ readonly mintBridgeToken?: (sandboxId: string) => string;
98
105
  };
@@ -20,6 +20,7 @@ export function createACPTurnStartConfig({
20
20
  builtinTools,
21
21
  permissionMode,
22
22
  permissionModeMapping,
23
+ mcpServers,
23
24
  debug,
24
25
  authenticationProfile,
25
26
  sessionMeta,
@@ -29,6 +30,7 @@ export function createACPTurnStartConfig({
29
30
  builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
30
31
  permissionMode: NonNullable<StartMessage['permissionMode']>;
31
32
  permissionModeMapping: ACPPermissionModeMapping | undefined;
33
+ mcpServers: Record<string, unknown> | undefined;
32
34
  debug: HarnessV1DebugConfig | undefined;
33
35
  authenticationProfile: ACPAuthenticationProfileIdentity;
34
36
  sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
@@ -42,6 +44,7 @@ export function createACPTurnStartConfig({
42
44
  sessionMeta: sessionMeta ?? null,
43
45
  builtinTools,
44
46
  permissionModeMapping: permissionModeMapping ?? null,
47
+ mcpServers: mcpServers ?? null,
45
48
  }),
46
49
  )
47
50
  .digest('hex'),
@@ -39,11 +39,37 @@ export function createACPBridgeError({
39
39
  | 'prompt update stream';
40
40
  cause: unknown;
41
41
  }): Error {
42
- const error = new Error(`ACP ${stage} failed.`);
42
+ const causeMessage = getErrorMessage({ error: cause });
43
+ const error = new Error(
44
+ causeMessage == null
45
+ ? `ACP ${stage} failed.`
46
+ : `ACP ${stage} failed: ${causeMessage}`,
47
+ );
43
48
  (error as Error & { cause?: unknown }).cause = cause;
44
49
  return error;
45
50
  }
46
51
 
52
+ function getErrorMessage({ error }: { error: unknown }): string | undefined {
53
+ if (error instanceof Error) {
54
+ return error.message;
55
+ }
56
+
57
+ if (
58
+ error != null &&
59
+ typeof error === 'object' &&
60
+ 'message' in error &&
61
+ typeof error.message === 'string'
62
+ ) {
63
+ return error.message;
64
+ }
65
+
66
+ if (typeof error === 'string') {
67
+ return error;
68
+ }
69
+
70
+ return undefined;
71
+ }
72
+
47
73
  function stripMetadata({ value }: { value: unknown }): unknown {
48
74
  if (Array.isArray(value)) {
49
75
  return value.map(item => stripMetadata({ value: item }));
@@ -0,0 +1,52 @@
1
+ import type { Readable } from 'node:stream';
2
+ import { stripVTControlCharacters } from 'node:util';
3
+
4
+ export function monitorACPAgentStderr({
5
+ stderr,
6
+ onStderrLine,
7
+ }: {
8
+ stderr: Readable;
9
+ onStderrLine: (line: string) => void;
10
+ }): Promise<never> {
11
+ let rejectFailure!: (error: unknown) => void;
12
+ const failure = new Promise<never>((_, reject) => {
13
+ rejectFailure = reject;
14
+ });
15
+ void failure.catch(() => {});
16
+
17
+ void (async () => {
18
+ stderr.setEncoding('utf8');
19
+ let pending = '';
20
+ for await (const chunk of stderr) {
21
+ pending += chunk;
22
+ const lines = pending.split('\n');
23
+ pending = lines.pop() ?? '';
24
+ for (const line of lines) handleStderrLine({ line });
25
+ }
26
+ if (pending.length > 0) handleStderrLine({ line: pending });
27
+ })().catch(error => rejectFailure(error));
28
+
29
+ return failure;
30
+
31
+ function handleStderrLine({ line }: { line: string }): void {
32
+ if (line.length === 0) return;
33
+ onStderrLine(line);
34
+ const normalizedLine = stripVTControlCharacters(line);
35
+
36
+ /*
37
+ * An ACP agent can log a response-stream decoding failure without rejecting
38
+ * its pending JSON-RPC prompt. Once the provider stream cannot be decoded,
39
+ * no valid prompt completion can reach the ACP client, so the bridge must
40
+ * fail the turn instead of waiting indefinitely.
41
+ */
42
+ if (
43
+ normalizedLine
44
+ .toLowerCase()
45
+ .includes('failed to deserialize responsestreamevent from stream')
46
+ ) {
47
+ rejectFailure(
48
+ new Error('ACP agent failed to deserialize a streamed response.'),
49
+ );
50
+ }
51
+ }
52
+ }
@@ -6,17 +6,20 @@ import type {
6
6
  HarnessV1BridgeToolWire,
7
7
  HarnessV1StreamPart,
8
8
  } from '@ai-sdk/harness';
9
+ import type { ACPToolCall } from '../../acp-tool-call';
9
10
  import type { ACPBuiltinToolMapping } from '../acp-v1-bridge-protocol';
10
11
  import { createACPStreamTranslator } from './stream-translator';
11
12
  import { createHostToolCorrelation } from './host-tool-correlation';
12
13
 
13
14
  export function createEmitStreamEvent({
14
15
  emit,
16
+ emitToolCallCandidate,
15
17
  builtinTools,
16
18
  hostToolServerName,
17
19
  hostTools,
18
20
  }: {
19
21
  emit: (event: HarnessV1StreamPart) => void;
22
+ emitToolCallCandidate: (options: { toolCall: ACPToolCall }) => void;
20
23
  builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
21
24
  hostToolServerName: string;
22
25
  hostTools: ReadonlyArray<HarnessV1BridgeToolWire>;
@@ -49,7 +52,11 @@ export function createEmitStreamEvent({
49
52
  }) => void;
50
53
  removeHostToolCorrelationInvocation: (options: { token: string }) => void;
51
54
  } {
52
- const translator = createACPStreamTranslator({ emit, builtinTools });
55
+ const translator = createACPStreamTranslator({
56
+ emit,
57
+ emitToolCallCandidate,
58
+ builtinTools,
59
+ });
53
60
  const correlation = createHostToolCorrelation({
54
61
  emitSemanticUpdate: ({ message, rawUpdate }) => {
55
62
  translator.update({
@@ -30,6 +30,7 @@ import {
30
30
  createACPBridgeError,
31
31
  createACPInitializationDiagnostic,
32
32
  } from './acp-diagnostics';
33
+ import { monitorACPAgentStderr } from './agent-stderr-monitor';
33
34
  import { createEmitStreamEvent } from './create-emit-stream-event';
34
35
  import {
35
36
  startHostToolRelay,
@@ -79,6 +80,7 @@ const implementation = await readImplementationDescriptor({
79
80
  const bridgeConfiguration = await readACPBridgeEnvironment({ env: processEnv });
80
81
 
81
82
  let child: ChildProcessWithoutNullStreams | undefined;
83
+ let agentResponseStreamFailure: Promise<never> | undefined;
82
84
  let connection: acp.ClientConnection | undefined;
83
85
  let session: ACPActiveSession | undefined;
84
86
  let recoveredSessionUpdates:
@@ -139,6 +141,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
139
141
  if (activeSession == null) {
140
142
  throw new Error('ACP session initialization did not produce a session.');
141
143
  }
144
+ const activeAgentResponseStreamFailure = agentResponseStreamFailure;
145
+ if (activeAgentResponseStreamFailure == null) {
146
+ throw new Error(
147
+ 'ACP session initialization did not start stderr monitoring.',
148
+ );
149
+ }
142
150
  if (start.recoveryMode?.type === 'lossy-rerun') {
143
151
  const marker = {
144
152
  type: 'acp-recovery',
@@ -222,6 +230,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
222
230
  turn.emit({ type: 'stream-start' });
223
231
  const emitStreamEvent = createEmitStreamEvent({
224
232
  emit: event => turn.emit(event as BridgeEvent),
233
+ emitToolCallCandidate: ({ toolCall }) => {
234
+ turn.emit({
235
+ type: 'acp-tool-call-candidate',
236
+ toolCall,
237
+ });
238
+ },
225
239
  builtinTools: start.builtinTools,
226
240
  hostToolServerName: HOST_TOOL_MCP_SERVER_NAME,
227
241
  hostTools: start.tools ?? [],
@@ -260,6 +274,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
260
274
  message = await Promise.race([
261
275
  activeSession.nextUpdate(),
262
276
  cancellationFailure,
277
+ activeAgentResponseStreamFailure,
263
278
  ]);
264
279
  } catch (error) {
265
280
  for (const rawValue of streamCapture?.drainRawValues() ?? []) {
@@ -313,6 +328,7 @@ async function ensureSession({
313
328
  sessionMeta: bridgeConfiguration.sessionMeta,
314
329
  permissionMode: start.permissionMode,
315
330
  permissionModeMapping: start.permissionModeMapping,
331
+ mcpServers: start.mcpServers,
316
332
  });
317
333
  if (session != null) {
318
334
  if (catalogRefreshError != null) throw catalogRefreshError;
@@ -361,7 +377,16 @@ async function ensureSession({
361
377
  stdio: ['pipe', 'pipe', 'pipe'],
362
378
  },
363
379
  );
364
- forwardAgentStderr({ child, turn });
380
+ agentResponseStreamFailure = monitorACPAgentStderr({
381
+ stderr: child.stderr,
382
+ onStderrLine: line => {
383
+ turn.bridgeLog({
384
+ level: 'warn',
385
+ subsystem: 'acp.agent.stderr',
386
+ message: line,
387
+ });
388
+ },
389
+ });
365
390
 
366
391
  const input = Writable.toWeb(child.stdin);
367
392
  const output = Readable.toWeb(child.stdout) as ReadableStream<
@@ -419,6 +444,10 @@ async function ensureSession({
419
444
  authentication,
420
445
  });
421
446
  }
447
+ const externalMcpServers = createExternalMcpServers({
448
+ mcpServers: start.mcpServers,
449
+ initialization,
450
+ });
422
451
  const tools = start.tools ?? [];
423
452
  const catalogPath = `${bridgeStateDir}/host-tools.json`;
424
453
  await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
@@ -428,6 +457,7 @@ async function ensureSession({
428
457
  });
429
458
 
430
459
  const mcpServers: acp.McpServer[] = [
460
+ ...externalMcpServers,
431
461
  {
432
462
  name: HOST_TOOL_MCP_SERVER_NAME,
433
463
  command: process.execPath,
@@ -539,6 +569,41 @@ async function ensureSession({
539
569
  sessionConfigurationFingerprint = fingerprint;
540
570
  }
541
571
 
572
+ function createExternalMcpServers({
573
+ mcpServers,
574
+ initialization,
575
+ }: {
576
+ mcpServers: Record<string, unknown> | undefined;
577
+ initialization: acp.InitializeResponse;
578
+ }): acp.McpServer[] {
579
+ if (mcpServers == null) return [];
580
+ return Object.entries(mcpServers).map(([name, value]) => {
581
+ if (!isRecord(value)) {
582
+ throw new Error(
583
+ `ACP MCP server ${JSON.stringify(name)} must be configured with an object value.`,
584
+ );
585
+ }
586
+ if (value.type === 'acp') {
587
+ throw new HarnessBridgeCapabilityUnsupportedError({
588
+ harnessId: bridgeType,
589
+ message:
590
+ 'ACP-transport MCP servers require client-side mcp/connect handling, which this harness does not provide.',
591
+ });
592
+ }
593
+ const mcpCapabilities = initialization.agentCapabilities?.mcpCapabilities;
594
+ if (
595
+ (value.type === 'http' && mcpCapabilities?.http !== true) ||
596
+ (value.type === 'sse' && mcpCapabilities?.sse !== true)
597
+ ) {
598
+ throw new HarnessBridgeCapabilityUnsupportedError({
599
+ harnessId: bridgeType,
600
+ message: `The ACP agent does not advertise support for ${value.type.toUpperCase()} MCP servers.`,
601
+ });
602
+ }
603
+ return { ...value, name } as acp.McpServer;
604
+ });
605
+ }
606
+
542
607
  function unknownUsage() {
543
608
  return {
544
609
  inputTokens: {
@@ -631,39 +696,6 @@ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
631
696
  return value != null && typeof value === 'object' && !Array.isArray(value);
632
697
  }
633
698
 
634
- function forwardAgentStderr({
635
- child,
636
- turn,
637
- }: {
638
- child: ChildProcessWithoutNullStreams;
639
- turn: BridgeTurn;
640
- }): void {
641
- void (async () => {
642
- child.stderr.setEncoding('utf8');
643
- let pending = '';
644
- for await (const chunk of child.stderr) {
645
- pending += chunk;
646
- const lines = pending.split('\n');
647
- pending = lines.pop() ?? '';
648
- for (const line of lines) {
649
- if (line.length === 0) continue;
650
- turn.bridgeLog({
651
- level: 'warn',
652
- subsystem: 'acp.agent.stderr',
653
- message: line,
654
- });
655
- }
656
- }
657
- if (pending.length > 0) {
658
- turn.bridgeLog({
659
- level: 'warn',
660
- subsystem: 'acp.agent.stderr',
661
- message: pending,
662
- });
663
- }
664
- })();
665
- }
666
-
667
699
  async function readImplementationDescriptor({
668
700
  path,
669
701
  }: {
@@ -1,5 +1,10 @@
1
1
  import type { HarnessV1StreamPart } from '@ai-sdk/harness';
2
2
  import type { PromptResponse, SessionUpdate } from '@agentclientprotocol/sdk';
3
+ import type {
4
+ ACPToolCall,
5
+ ACPToolCallStatus,
6
+ ACPToolKind,
7
+ } from '../../acp-tool-call';
3
8
  import type { ACPBuiltinToolMapping } from '../acp-v1-bridge-protocol';
4
9
 
5
10
  type HarnessUsage = Extract<
@@ -20,7 +25,6 @@ type ToolState = {
20
25
  emittedResult: boolean;
21
26
  toolName?: string;
22
27
  nativeName?: string;
23
- dynamic?: boolean;
24
28
  };
25
29
 
26
30
  export type ACPSessionUpdate = SessionUpdate;
@@ -28,9 +32,11 @@ export type ACPPromptResponse = PromptResponse;
28
32
 
29
33
  export function createACPStreamTranslator({
30
34
  emit,
35
+ emitToolCallCandidate,
31
36
  builtinTools = [],
32
37
  }: {
33
38
  emit: (event: HarnessV1StreamPart) => void;
39
+ emitToolCallCandidate?: (options: { toolCall: ACPToolCall }) => void;
34
40
  builtinTools?: ReadonlyArray<ACPBuiltinToolMapping>;
35
41
  }): {
36
42
  update: (
@@ -228,13 +234,12 @@ export function createACPStreamTranslator({
228
234
  builtin.nativeName != null && builtin.toolName !== builtin.nativeName
229
235
  ? builtin.nativeName
230
236
  : undefined;
231
- state.dynamic = false;
232
237
  } else {
233
238
  state.toolName = createDynamicToolName({
234
239
  programmaticName,
235
240
  toolCallId: state.toolCallId,
236
241
  });
237
- state.dynamic = true;
242
+ emitToolCallCandidate?.({ toolCall: createACPToolCall({ state }) });
238
243
  }
239
244
  emit({
240
245
  type: 'tool-call',
@@ -242,7 +247,6 @@ export function createACPStreamTranslator({
242
247
  toolName: state.toolName,
243
248
  input: stringifyToolInput({ input: state.values.rawInput }),
244
249
  providerExecuted: true,
245
- ...(state.dynamic ? { dynamic: true } : {}),
246
250
  ...(state.nativeName == null ? {} : { nativeName: state.nativeName }),
247
251
  });
248
252
  state.emittedCall = true;
@@ -263,7 +267,6 @@ export function createACPStreamTranslator({
263
267
  toolName: state.toolName!,
264
268
  result: createToolResult({ state }),
265
269
  ...(state.values.status === 'failed' ? { isError: true } : {}),
266
- ...(state.dynamic ? { dynamic: true } : {}),
267
270
  });
268
271
  state.emittedResult = true;
269
272
  pendingToolCallIds.delete(state.toolCallId);
@@ -697,6 +700,60 @@ function mergeToolUpdate({
697
700
  if (programmaticName != null) state.values.name = programmaticName;
698
701
  }
699
702
 
703
+ function createACPToolCall({ state }: { state: ToolState }): ACPToolCall {
704
+ const title = getStringProperty({
705
+ value: state.values,
706
+ property: 'title',
707
+ });
708
+ const kind = state.values.kind;
709
+ const status = state.values.status;
710
+ return {
711
+ toolCallId: state.toolCallId,
712
+ title: title ?? state.toolName ?? `Tool ${state.toolCallId}`,
713
+ ...(isACPToolKind(kind) ? { kind } : {}),
714
+ ...(isACPToolCallStatus(status) ? { status } : {}),
715
+ ...(Array.isArray(state.values.content)
716
+ ? { content: state.values.content as ACPToolCall['content'] }
717
+ : {}),
718
+ ...(Array.isArray(state.values.locations)
719
+ ? { locations: state.values.locations as ACPToolCall['locations'] }
720
+ : {}),
721
+ ...(Object.prototype.hasOwnProperty.call(state.values, 'rawInput')
722
+ ? { rawInput: state.values.rawInput }
723
+ : {}),
724
+ ...(Object.prototype.hasOwnProperty.call(state.values, 'rawOutput')
725
+ ? { rawOutput: state.values.rawOutput }
726
+ : {}),
727
+ ...(state.values._meta === null || isRecord(state.values._meta)
728
+ ? { _meta: state.values._meta }
729
+ : {}),
730
+ };
731
+ }
732
+
733
+ function isACPToolKind(value: unknown): value is ACPToolKind {
734
+ return (
735
+ value === 'read' ||
736
+ value === 'edit' ||
737
+ value === 'delete' ||
738
+ value === 'move' ||
739
+ value === 'search' ||
740
+ value === 'execute' ||
741
+ value === 'think' ||
742
+ value === 'fetch' ||
743
+ value === 'switch_mode' ||
744
+ value === 'other'
745
+ );
746
+ }
747
+
748
+ function isACPToolCallStatus(value: unknown): value is ACPToolCallStatus {
749
+ return (
750
+ value === 'pending' ||
751
+ value === 'in_progress' ||
752
+ value === 'completed' ||
753
+ value === 'failed'
754
+ );
755
+ }
756
+
700
757
  function emitFileChanges({
701
758
  state,
702
759
  emit,