@ai-sdk/harness-acp 1.0.1 → 1.0.3

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.
@@ -10,6 +10,7 @@ import {
10
10
  } from './acp-v1-bridge-protocol';
11
11
  import type { ACPTextContentBlock } from './acp-v1-prompt';
12
12
  import type {
13
+ ACPInstructionMapping,
13
14
  ACPPermissionModeMapping,
14
15
  ACPSerializableValue,
15
16
  } from './acp-v1-settings';
@@ -20,18 +21,22 @@ export function createACPTurnStartConfig({
20
21
  builtinTools,
21
22
  permissionMode,
22
23
  permissionModeMapping,
24
+ mcpServers,
23
25
  debug,
24
26
  authenticationProfile,
25
27
  sessionMeta,
28
+ instructionMapping,
26
29
  }: {
27
30
  prompt: ReadonlyArray<ACPTextContentBlock>;
28
31
  tools: ReadonlyArray<HarnessV1ToolSpec>;
29
32
  builtinTools: ReadonlyArray<ACPBuiltinToolMapping>;
30
33
  permissionMode: NonNullable<StartMessage['permissionMode']>;
31
34
  permissionModeMapping: ACPPermissionModeMapping | undefined;
35
+ mcpServers: Record<string, unknown> | undefined;
32
36
  debug: HarnessV1DebugConfig | undefined;
33
37
  authenticationProfile: ACPAuthenticationProfileIdentity;
34
38
  sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
39
+ instructionMapping: ACPInstructionMapping | undefined;
35
40
  }): ACPTurnStartConfig {
36
41
  return {
37
42
  version: 1,
@@ -40,8 +45,10 @@ export function createACPTurnStartConfig({
40
45
  stableStringify({
41
46
  authenticationProfile,
42
47
  sessionMeta: sessionMeta ?? null,
48
+ ...(instructionMapping == null ? {} : { instructionMapping }),
43
49
  builtinTools,
44
50
  permissionModeMapping: permissionModeMapping ?? null,
51
+ mcpServers: mcpServers ?? null,
45
52
  }),
46
53
  )
47
54
  .digest('hex'),
@@ -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({
@@ -32,6 +32,7 @@ import {
32
32
  } from './acp-diagnostics';
33
33
  import { monitorACPAgentStderr } from './agent-stderr-monitor';
34
34
  import { createEmitStreamEvent } from './create-emit-stream-event';
35
+ import { resolveACPInstructionConfiguration } from './instruction-mapping';
35
36
  import {
36
37
  startHostToolRelay,
37
38
  type HostToolRelay,
@@ -230,6 +231,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
230
231
  turn.emit({ type: 'stream-start' });
231
232
  const emitStreamEvent = createEmitStreamEvent({
232
233
  emit: event => turn.emit(event as BridgeEvent),
234
+ emitToolCallCandidate: ({ toolCall }) => {
235
+ turn.emit({
236
+ type: 'acp-tool-call-candidate',
237
+ toolCall,
238
+ });
239
+ },
233
240
  builtinTools: start.builtinTools,
234
241
  hostToolServerName: HOST_TOOL_MCP_SERVER_NAME,
235
242
  hostTools: start.tools ?? [],
@@ -320,8 +327,10 @@ async function ensureSession({
320
327
  authentication: bridgeConfiguration.authentication,
321
328
  providerAuthentication: bridgeConfiguration.providerAuthentication,
322
329
  sessionMeta: bridgeConfiguration.sessionMeta,
330
+ instructionMapping: start.instructionMapping,
323
331
  permissionMode: start.permissionMode,
324
332
  permissionModeMapping: start.permissionModeMapping,
333
+ mcpServers: start.mcpServers,
325
334
  });
326
335
  if (session != null) {
327
336
  if (catalogRefreshError != null) throw catalogRefreshError;
@@ -360,12 +369,18 @@ async function ensureSession({
360
369
  providerAuthentication: bridgeConfiguration.providerAuthentication,
361
370
  gateway,
362
371
  });
372
+ const instructionConfiguration = await resolveACPInstructionConfiguration({
373
+ instructions: start.instructions,
374
+ instructionMapping: start.instructionMapping,
375
+ sessionMeta: bridgeConfiguration.sessionMeta,
376
+ environment: createChildEnvironment({ launchEnv }),
377
+ });
363
378
  child = spawn(
364
379
  `${implementationDir}/node_modules/.bin/${implementation.executable}`,
365
380
  [...implementation.args],
366
381
  {
367
382
  cwd: workDir,
368
- env: createChildEnvironment({ launchEnv }),
383
+ env: instructionConfiguration.environment,
369
384
  shell: false,
370
385
  stdio: ['pipe', 'pipe', 'pipe'],
371
386
  },
@@ -437,6 +452,10 @@ async function ensureSession({
437
452
  authentication,
438
453
  });
439
454
  }
455
+ const externalMcpServers = createExternalMcpServers({
456
+ mcpServers: start.mcpServers,
457
+ initialization,
458
+ });
440
459
  const tools = start.tools ?? [];
441
460
  const catalogPath = `${bridgeStateDir}/host-tools.json`;
442
461
  await writeFile(catalogPath, JSON.stringify(tools), { mode: 0o600 });
@@ -446,6 +465,7 @@ async function ensureSession({
446
465
  });
447
466
 
448
467
  const mcpServers: acp.McpServer[] = [
468
+ ...externalMcpServers,
449
469
  {
450
470
  name: HOST_TOOL_MCP_SERVER_NAME,
451
471
  command: process.execPath,
@@ -478,9 +498,9 @@ async function ensureSession({
478
498
  sessionId: recoveredSessionId,
479
499
  cwd: workDir,
480
500
  mcpServers,
481
- ...(bridgeConfiguration.sessionMeta == null
501
+ ...(instructionConfiguration.sessionMeta == null
482
502
  ? {}
483
- : { _meta: bridgeConfiguration.sessionMeta }),
503
+ : { _meta: instructionConfiguration.sessionMeta }),
484
504
  });
485
505
  createdSession = createACPRecoveredSession({
486
506
  agent: connection.agent,
@@ -497,7 +517,7 @@ async function ensureSession({
497
517
  sessionId: recoveredSessionId,
498
518
  cwd: workDir,
499
519
  mcpServers,
500
- meta: bridgeConfiguration.sessionMeta,
520
+ meta: instructionConfiguration.sessionMeta,
501
521
  harnessId: bridgeType,
502
522
  setHistoricalUpdatesSuppressed: ({ suppressed }) => {
503
523
  historicalUpdatesSuppressed = suppressed;
@@ -518,9 +538,9 @@ async function ensureSession({
518
538
  .buildSession({
519
539
  cwd: workDir,
520
540
  mcpServers,
521
- ...(bridgeConfiguration.sessionMeta == null
541
+ ...(instructionConfiguration.sessionMeta == null
522
542
  ? {}
523
- : { _meta: bridgeConfiguration.sessionMeta }),
543
+ : { _meta: instructionConfiguration.sessionMeta }),
524
544
  })
525
545
  .start();
526
546
  }
@@ -557,6 +577,41 @@ async function ensureSession({
557
577
  sessionConfigurationFingerprint = fingerprint;
558
578
  }
559
579
 
580
+ function createExternalMcpServers({
581
+ mcpServers,
582
+ initialization,
583
+ }: {
584
+ mcpServers: Record<string, unknown> | undefined;
585
+ initialization: acp.InitializeResponse;
586
+ }): acp.McpServer[] {
587
+ if (mcpServers == null) return [];
588
+ return Object.entries(mcpServers).map(([name, value]) => {
589
+ if (!isRecord(value)) {
590
+ throw new Error(
591
+ `ACP MCP server ${JSON.stringify(name)} must be configured with an object value.`,
592
+ );
593
+ }
594
+ if (value.type === 'acp') {
595
+ throw new HarnessBridgeCapabilityUnsupportedError({
596
+ harnessId: bridgeType,
597
+ message:
598
+ 'ACP-transport MCP servers require client-side mcp/connect handling, which this harness does not provide.',
599
+ });
600
+ }
601
+ const mcpCapabilities = initialization.agentCapabilities?.mcpCapabilities;
602
+ if (
603
+ (value.type === 'http' && mcpCapabilities?.http !== true) ||
604
+ (value.type === 'sse' && mcpCapabilities?.sse !== true)
605
+ ) {
606
+ throw new HarnessBridgeCapabilityUnsupportedError({
607
+ harnessId: bridgeType,
608
+ message: `The ACP agent does not advertise support for ${value.type.toUpperCase()} MCP servers.`,
609
+ });
610
+ }
611
+ return { ...value, name } as acp.McpServer;
612
+ });
613
+ }
614
+
560
615
  function unknownUsage() {
561
616
  return {
562
617
  inputTokens: {
@@ -0,0 +1,132 @@
1
+ import { z } from 'zod/v4';
2
+ import type {
3
+ ACPInstructionMapping,
4
+ ACPSerializableValue,
5
+ } from '../acp-v1-settings';
6
+
7
+ const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
8
+
9
+ const serializableRecordSchema: z.ZodType<
10
+ Readonly<Record<string, ACPSerializableValue>>
11
+ > = z.record(z.string(), z.json());
12
+
13
+ export async function resolveACPInstructionConfiguration({
14
+ instructions,
15
+ instructionMapping,
16
+ sessionMeta,
17
+ environment,
18
+ }: {
19
+ instructions: string | undefined;
20
+ instructionMapping: ACPInstructionMapping | undefined;
21
+ sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
22
+ environment: Readonly<Record<string, string | undefined>>;
23
+ }): Promise<{
24
+ sessionMeta: Readonly<Record<string, ACPSerializableValue>> | undefined;
25
+ environment: Record<string, string | undefined>;
26
+ }> {
27
+ const resolvedEnvironment = { ...environment };
28
+ if (
29
+ instructionMapping == null ||
30
+ instructions == null ||
31
+ instructions.length === 0
32
+ ) {
33
+ return { sessionMeta, environment: resolvedEnvironment };
34
+ }
35
+
36
+ assertSafePath({ path: instructionMapping.path });
37
+
38
+ if (instructionMapping.type === 'session-meta') {
39
+ return {
40
+ sessionMeta: setStringAtPath({
41
+ record: sessionMeta ?? {},
42
+ path: instructionMapping.path,
43
+ value: instructions,
44
+ }),
45
+ environment: resolvedEnvironment,
46
+ };
47
+ }
48
+
49
+ const serialized = resolvedEnvironment[instructionMapping.variable];
50
+ let configuration: Readonly<Record<string, ACPSerializableValue>> = {};
51
+ if (serialized != null && serialized.length > 0) {
52
+ configuration = parseSerializableRecord({
53
+ serialized,
54
+ variable: instructionMapping.variable,
55
+ });
56
+ }
57
+
58
+ resolvedEnvironment[instructionMapping.variable] = JSON.stringify(
59
+ setStringAtPath({
60
+ record: configuration,
61
+ path: instructionMapping.path,
62
+ value: instructions,
63
+ }),
64
+ );
65
+
66
+ return { sessionMeta, environment: resolvedEnvironment };
67
+ }
68
+
69
+ /*
70
+ * The bridge runs in an isolated runtime whose dependencies are limited to the
71
+ * ones declared in this directory's package.json, so AI SDK JSON helpers are
72
+ * unavailable here. Schema validation plus a guarded `JSON.parse` provides the
73
+ * same guarantee: nothing but a validated serializable record escapes.
74
+ */
75
+ function parseSerializableRecord({
76
+ serialized,
77
+ variable,
78
+ }: {
79
+ serialized: string;
80
+ variable: string;
81
+ }): Readonly<Record<string, ACPSerializableValue>> {
82
+ try {
83
+ const result = serializableRecordSchema.safeParse(JSON.parse(serialized));
84
+ if (result.success) return result.data;
85
+ } catch {}
86
+ throw new Error(
87
+ `ACP instruction mapping environment variable ${JSON.stringify(variable)} must contain a JSON object.`,
88
+ );
89
+ }
90
+
91
+ function setStringAtPath({
92
+ record,
93
+ path,
94
+ value,
95
+ }: {
96
+ record: Readonly<Record<string, ACPSerializableValue>>;
97
+ path: ReadonlyArray<string>;
98
+ value: string;
99
+ }): Record<string, ACPSerializableValue> {
100
+ const [key, ...remainingPath] = path;
101
+ if (key == null) return { ...record };
102
+ return {
103
+ ...record,
104
+ [key]:
105
+ remainingPath.length === 0
106
+ ? value
107
+ : setStringAtPath({
108
+ record: isRecord(record[key]) ? record[key] : {},
109
+ path: remainingPath,
110
+ value,
111
+ }),
112
+ };
113
+ }
114
+
115
+ function assertSafePath({ path }: { path: ReadonlyArray<string> }): void {
116
+ if (
117
+ path.length === 0 ||
118
+ path.some(
119
+ segment => segment.length === 0 || UNSAFE_PATH_SEGMENTS.has(segment),
120
+ )
121
+ ) {
122
+ throw new Error(
123
+ 'ACP instruction mapping path must contain only safe, non-empty property names.',
124
+ );
125
+ }
126
+ }
127
+
128
+ function isRecord(
129
+ value: ACPSerializableValue | undefined,
130
+ ): value is Readonly<Record<string, ACPSerializableValue>> {
131
+ return value != null && typeof value === 'object' && !Array.isArray(value);
132
+ }
@@ -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,
package/src/v1/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { createACPV1 } from './acp-v1-harness';
2
2
  export type {
3
3
  ACPAuthentication,
4
+ ACPInstructionMapping,
4
5
  ACPNpmLockedSource,
5
6
  ACPNpmSimpleSource,
6
7
  ACPPermissionModeMapping,