@ai-sdk/harness 1.0.10 → 1.0.12

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,11 +1,14 @@
1
- import type {
2
- HarnessV1,
3
- HarnessV1PendingToolApproval,
4
- HarnessV1Prompt,
5
- HarnessV1PromptControl,
6
- HarnessV1Session,
7
- HarnessV1StreamPart,
8
- HarnessV1ToolSpec,
1
+ import {
2
+ getHarnessV1BuiltinToolFilteringDenialReason,
3
+ isHarnessV1BuiltinToolIncluded,
4
+ type HarnessV1,
5
+ type HarnessV1BuiltinToolFiltering,
6
+ type HarnessV1PendingToolApproval,
7
+ type HarnessV1Prompt,
8
+ type HarnessV1PromptControl,
9
+ type HarnessV1Session,
10
+ type HarnessV1StreamPart,
11
+ type HarnessV1ToolSpec,
9
12
  } from '../../v1';
10
13
  import { toHarnessStream } from './to-harness-stream';
11
14
  import {
@@ -60,7 +63,9 @@ export function runPrompt<
60
63
  prompt?: HarnessV1Prompt;
61
64
  instructions: string | undefined;
62
65
  tools: TOOLS;
66
+ activeTools?: ToolSet;
63
67
  toolSpecs: HarnessV1ToolSpec[];
68
+ builtinToolFiltering?: HarnessV1BuiltinToolFiltering | undefined;
64
69
  sandboxSession: SandboxSession;
65
70
  sessionWorkDir: string;
66
71
  runtimeContext: RUNTIME_CONTEXT;
@@ -89,6 +94,7 @@ export function runPrompt<
89
94
  const pendingToolApprovals = input.pendingToolApprovals ?? [];
90
95
  const onPendingToolApproval = input.onPendingToolApproval ?? (() => {});
91
96
  const onToolApprovalSettled = input.onToolApprovalSettled ?? (() => {});
97
+ const activeTools = input.activeTools ?? input.tools;
92
98
 
93
99
  const telemetry = createTurnTelemetry({
94
100
  telemetry: input.telemetry,
@@ -304,7 +310,7 @@ export function runPrompt<
304
310
 
305
311
  const outcome = await maybeExecuteHostTool({
306
312
  event: rawToolCall,
307
- tools: input.tools,
313
+ tools: activeTools,
308
314
  sandboxSession: input.sandboxSession,
309
315
  abortSignal: input.abortSignal,
310
316
  control,
@@ -379,6 +385,39 @@ export function runPrompt<
379
385
  continue;
380
386
  }
381
387
 
388
+ if (displayValue.type === 'tool-approval-request') {
389
+ const toolCall = toolCallsByToolCallId.get(displayValue.toolCallId);
390
+ if (toolCall == null) {
391
+ throw new Error(
392
+ `Harness '${input.harness.harnessId}' emitted approval request '${displayValue.approvalId}' for unknown tool call '${displayValue.toolCallId}'.`,
393
+ );
394
+ }
395
+ const rawToolCall = rawToolCallsByToolCallId.get(
396
+ displayValue.toolCallId,
397
+ );
398
+ const toolName = rawToolCall?.toolName ?? toolCall.toolName;
399
+ if (
400
+ !isHarnessV1BuiltinToolIncluded({
401
+ toolName,
402
+ toolFiltering: input.builtinToolFiltering,
403
+ })
404
+ ) {
405
+ if (control.submitToolApproval == null) {
406
+ throw new Error(
407
+ `Harness '${input.harness.harnessId}' emitted a built-in tool approval request but does not support approval responses.`,
408
+ );
409
+ }
410
+ await control.submitToolApproval({
411
+ approvalId: displayValue.approvalId,
412
+ approved: false,
413
+ reason: getHarnessV1BuiltinToolFilteringDenialReason({
414
+ toolName,
415
+ }),
416
+ });
417
+ continue;
418
+ }
419
+ }
420
+
382
421
  // Forward to consumer as soon as possible.
383
422
  for (const part of translateStreamPart<TOOLS>(displayValue)) {
384
423
  result.enqueue(part);
@@ -514,6 +553,20 @@ export function runPrompt<
514
553
  `Harness '${input.harness.harnessId}' could not find parsed tool call '${toolCall.toolCallId}' for custom tool approval.`,
515
554
  );
516
555
  }
556
+ if (!hasTool({ tools: activeTools, toolName: toolCall.toolName })) {
557
+ const output = {
558
+ type: 'execution-denied',
559
+ reason: getHarnessV1BuiltinToolFilteringDenialReason({
560
+ toolName: toolCall.toolName,
561
+ }),
562
+ };
563
+ await control.submitToolResult({
564
+ toolCallId: toolCall.toolCallId,
565
+ output,
566
+ });
567
+ telemetry.toolEnd(toolCall.toolCallId, { ok: true, output });
568
+ continue;
569
+ }
517
570
  const customToolApprovalDecision = resolveCustomToolApproval({
518
571
  toolName: toolCall.toolName,
519
572
  toolApproval: input.toolApproval,
@@ -595,7 +648,7 @@ export function runPrompt<
595
648
  }
596
649
  const outcome = await maybeExecuteHostTool({
597
650
  event: toolCall,
598
- tools: input.tools,
651
+ tools: activeTools,
599
652
  sandboxSession: input.sandboxSession,
600
653
  abortSignal: input.abortSignal,
601
654
  control,
@@ -694,6 +747,10 @@ type ToolCallTextStreamPart = {
694
747
  readonly title?: string;
695
748
  };
696
749
 
750
+ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
751
+ return Object.prototype.hasOwnProperty.call(input.tools, input.toolName);
752
+ }
753
+
697
754
  async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
698
755
  event: { toolCallId: string; toolName: string; input: string };
699
756
  tools: TOOLS;
@@ -0,0 +1,114 @@
1
+ import { HarnessCapabilityUnsupportedError } from '../../errors/harness-capability-unsupported-error';
2
+ import type { HarnessV1, HarnessV1BuiltinToolFiltering } from '../../v1';
3
+ import { NoSuchToolError, type ActiveTools } from 'ai';
4
+ import type { ToolSet } from '@ai-sdk/provider-utils';
5
+
6
+ export type ResolvedHarnessAgentToolFiltering<TUserTools extends ToolSet> = {
7
+ readonly activeUserTools: TUserTools;
8
+ readonly builtinToolFiltering?: HarnessV1BuiltinToolFiltering;
9
+ };
10
+
11
+ export function resolveHarnessAgentToolFiltering<
12
+ TAllTools extends ToolSet,
13
+ TUserTools extends ToolSet,
14
+ >(input: {
15
+ harness: HarnessV1;
16
+ userTools: TUserTools;
17
+ allTools: TAllTools;
18
+ activeTools: ActiveTools<TAllTools>;
19
+ inactiveTools: ActiveTools<TAllTools>;
20
+ }): ResolvedHarnessAgentToolFiltering<TUserTools> {
21
+ if (input.activeTools !== undefined && input.inactiveTools !== undefined) {
22
+ throw new Error(
23
+ 'HarnessAgent: pass either `activeTools` or `inactiveTools`, not both.',
24
+ );
25
+ }
26
+
27
+ const allToolNames = Object.keys(input.allTools);
28
+ const activeTools = dedupeToolNames({ toolNames: input.activeTools });
29
+ const inactiveTools = dedupeToolNames({ toolNames: input.inactiveTools });
30
+ validateToolNames({
31
+ requestedToolNames: activeTools ?? inactiveTools,
32
+ availableToolNames: allToolNames,
33
+ });
34
+
35
+ const userToolNames = Object.keys(input.userTools);
36
+ const activeUserToolNames =
37
+ activeTools != null
38
+ ? userToolNames.filter(name => activeTools.includes(name))
39
+ : inactiveTools != null
40
+ ? userToolNames.filter(name => !inactiveTools.includes(name))
41
+ : userToolNames;
42
+
43
+ const builtinToolNames = Object.keys(input.harness.builtinTools);
44
+ const disabledBuiltinToolNames =
45
+ activeTools != null
46
+ ? builtinToolNames.filter(name => !activeTools.includes(name))
47
+ : inactiveTools != null
48
+ ? builtinToolNames.filter(name => inactiveTools.includes(name))
49
+ : [];
50
+
51
+ const builtinToolFiltering =
52
+ disabledBuiltinToolNames.length > 0
53
+ ? activeTools != null
54
+ ? {
55
+ mode: 'allow' as const,
56
+ toolNames: builtinToolNames.filter(name =>
57
+ activeTools.includes(name),
58
+ ),
59
+ }
60
+ : { mode: 'deny' as const, toolNames: disabledBuiltinToolNames }
61
+ : undefined;
62
+
63
+ if (
64
+ builtinToolFiltering != null &&
65
+ input.harness.supportsBuiltinToolFiltering !== true &&
66
+ input.harness.supportsBuiltinToolApprovals !== true
67
+ ) {
68
+ throw new HarnessCapabilityUnsupportedError({
69
+ message: `Harness '${input.harness.harnessId}' does not support built-in tool filtering controls.`,
70
+ harnessId: input.harness.harnessId,
71
+ });
72
+ }
73
+
74
+ return {
75
+ activeUserTools: filterToolSet({
76
+ tools: input.userTools,
77
+ toolNames: activeUserToolNames,
78
+ }),
79
+ ...(builtinToolFiltering != null ? { builtinToolFiltering } : {}),
80
+ };
81
+ }
82
+
83
+ function dedupeToolNames(input: {
84
+ toolNames: ReadonlyArray<string> | undefined;
85
+ }): ReadonlyArray<string> | undefined {
86
+ return input.toolNames == null
87
+ ? undefined
88
+ : Array.from(new Set(input.toolNames));
89
+ }
90
+
91
+ function validateToolNames(input: {
92
+ requestedToolNames: ReadonlyArray<string> | undefined;
93
+ availableToolNames: ReadonlyArray<string>;
94
+ }): void {
95
+ if (input.requestedToolNames == null) return;
96
+ for (const toolName of input.requestedToolNames) {
97
+ if (!input.availableToolNames.includes(toolName)) {
98
+ throw new NoSuchToolError({
99
+ toolName,
100
+ availableTools: [...input.availableToolNames],
101
+ });
102
+ }
103
+ }
104
+ }
105
+
106
+ function filterToolSet<TUserTools extends ToolSet>(input: {
107
+ tools: TUserTools;
108
+ toolNames: ReadonlyArray<string>;
109
+ }): TUserTools {
110
+ const allowed = new Set(input.toolNames);
111
+ return Object.fromEntries(
112
+ Object.entries(input.tools).filter(([name]) => allowed.has(name)),
113
+ ) as TUserTools;
114
+ }
@@ -6,6 +6,13 @@ export {
6
6
  } from './sandbox-channel';
7
7
  export { classifyDiskLog, type DiskLogRecoveryMode } from './classify-disk-log';
8
8
  export { getAiGatewayAuthFromEnv } from './ai-gateway-auth';
9
+ export { resolveSandboxHomeDir } from './sandbox-home-dir';
10
+ export { shellQuote } from './shell-quote';
11
+ export {
12
+ writeSkills,
13
+ type SkillFilePathMode,
14
+ type WriteSkillsOptions,
15
+ } from './write-skills';
9
16
  export {
10
17
  markBridgeStarting,
11
18
  waitForBridgeReady,
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
3
+
4
+ export async function resolveSandboxHomeDir({
5
+ sandbox,
6
+ abortSignal,
7
+ }: {
8
+ sandbox: Experimental_SandboxSession;
9
+ abortSignal?: AbortSignal;
10
+ }): Promise<string> {
11
+ const result = await sandbox.run({
12
+ command: 'printf "%s" "$HOME"',
13
+ abortSignal,
14
+ });
15
+ const homeDir = result.stdout.trim();
16
+ if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
17
+ throw new Error(
18
+ `Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
19
+ );
20
+ }
21
+ return homeDir;
22
+ }
@@ -0,0 +1,3 @@
1
+ export function shellQuote(value: string): string {
2
+ return `'${value.replace(/'/g, `'\\''`)}'`;
3
+ }
@@ -0,0 +1,141 @@
1
+ import path from 'node:path';
2
+ import type { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
3
+ import type { HarnessV1Skill } from '../v1';
4
+ import { shellQuote } from './shell-quote';
5
+
6
+ export type SkillFilePathMode = 'relative' | 'strip-leading-slashes';
7
+
8
+ export type WriteSkillsOptions = {
9
+ sandbox: Experimental_SandboxSession;
10
+ rootDir: string;
11
+ skills: ReadonlyArray<HarnessV1Skill>;
12
+ abortSignal?: AbortSignal;
13
+ skillNamePattern?: RegExp;
14
+ invalidSkillNameMessage?: (input: { name: string }) => string;
15
+ filePathMode?: SkillFilePathMode;
16
+ invalidSkillFilePathMessage?: (input: {
17
+ skillName: string;
18
+ filePath: string;
19
+ }) => string;
20
+ trailingNewline?: boolean;
21
+ };
22
+
23
+ export async function writeSkills({
24
+ sandbox,
25
+ rootDir,
26
+ skills,
27
+ abortSignal,
28
+ skillNamePattern = /^[A-Za-z0-9._-]+$/,
29
+ invalidSkillNameMessage = ({ name }) => `Invalid skill name: ${name}`,
30
+ filePathMode = 'relative',
31
+ invalidSkillFilePathMessage = ({ skillName, filePath }) =>
32
+ `Invalid skill file path for ${skillName}: ${filePath}`,
33
+ trailingNewline = false,
34
+ }: WriteSkillsOptions): Promise<void> {
35
+ for (const skill of skills) {
36
+ validateSkillName({
37
+ name: skill.name,
38
+ pattern: skillNamePattern,
39
+ message: invalidSkillNameMessage,
40
+ });
41
+ for (const file of skill.files ?? []) {
42
+ normalizeSkillFilePath({
43
+ skillName: skill.name,
44
+ filePath: file.path,
45
+ mode: filePathMode,
46
+ message: invalidSkillFilePathMessage,
47
+ });
48
+ }
49
+ }
50
+
51
+ await sandbox.run({
52
+ command: `mkdir -p ${shellQuote(rootDir)}`,
53
+ abortSignal,
54
+ });
55
+
56
+ for (const skill of skills) {
57
+ const name = validateSkillName({
58
+ name: skill.name,
59
+ pattern: skillNamePattern,
60
+ message: invalidSkillNameMessage,
61
+ });
62
+ const skillDir = path.posix.join(rootDir, name);
63
+ await sandbox.writeTextFile({
64
+ path: path.posix.join(skillDir, 'SKILL.md'),
65
+ content: renderSkillFile({ skill, trailingNewline }),
66
+ abortSignal,
67
+ });
68
+
69
+ for (const file of skill.files ?? []) {
70
+ const filePath = normalizeSkillFilePath({
71
+ skillName: skill.name,
72
+ filePath: file.path,
73
+ mode: filePathMode,
74
+ message: invalidSkillFilePathMessage,
75
+ });
76
+ await sandbox.writeTextFile({
77
+ path: path.posix.join(skillDir, filePath),
78
+ content: file.content,
79
+ abortSignal,
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+ function validateSkillName({
86
+ name,
87
+ pattern,
88
+ message,
89
+ }: {
90
+ name: string;
91
+ pattern: RegExp;
92
+ message?: (input: { name: string }) => string;
93
+ }): string {
94
+ if (!pattern.test(name) || name === '.' || name === '..') {
95
+ throw new Error(message?.({ name }) ?? `Invalid skill name: ${name}`);
96
+ }
97
+ return name;
98
+ }
99
+
100
+ function normalizeSkillFilePath({
101
+ skillName,
102
+ filePath,
103
+ mode,
104
+ message,
105
+ }: {
106
+ skillName?: string;
107
+ filePath: string;
108
+ mode: SkillFilePathMode;
109
+ message?: (input: { skillName: string; filePath: string }) => string;
110
+ }): string {
111
+ const normalized =
112
+ mode === 'strip-leading-slashes'
113
+ ? filePath.replace(/^\/+/, '')
114
+ : path.posix.normalize(filePath);
115
+ const invalid =
116
+ normalized === '' ||
117
+ (mode === 'relative' && normalized === '.') ||
118
+ normalized.startsWith('../') ||
119
+ normalized.includes('/../') ||
120
+ normalized.endsWith('/..') ||
121
+ (mode === 'relative' && path.posix.isAbsolute(normalized));
122
+
123
+ if (invalid) {
124
+ throw new Error(
125
+ message?.({ skillName: skillName ?? '', filePath }) ??
126
+ `Invalid skill file path for ${skillName}: ${filePath}`,
127
+ );
128
+ }
129
+ return normalized;
130
+ }
131
+
132
+ function renderSkillFile({
133
+ skill,
134
+ trailingNewline,
135
+ }: {
136
+ skill: HarnessV1Skill;
137
+ trailingNewline: boolean;
138
+ }): string {
139
+ const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
140
+ return trailingNewline ? `${content}\n` : content;
141
+ }
@@ -73,6 +73,20 @@ export const harnessV1BridgePermissionModeSchema = z.enum([
73
73
  'allow-all',
74
74
  ]);
75
75
 
76
+ export const harnessV1BridgeBuiltinToolFilteringSchema = z.discriminatedUnion(
77
+ 'mode',
78
+ [
79
+ z.object({
80
+ mode: z.literal('allow'),
81
+ toolNames: z.array(z.string()),
82
+ }),
83
+ z.object({
84
+ mode: z.literal('deny'),
85
+ toolNames: z.array(z.string()),
86
+ }),
87
+ ],
88
+ );
89
+
76
90
  /**
77
91
  * Common fields of the inbound `start` message. Each adapter extends this with
78
92
  * its runtime-specific configuration (e.g. `thinking`/`continue` for Claude
@@ -90,6 +104,7 @@ export const harnessV1BridgeStartBaseSchema = z.object({
90
104
  model: z.string().optional(),
91
105
  debug: harnessV1DebugConfigSchema.optional(),
92
106
  permissionMode: harnessV1BridgePermissionModeSchema.optional(),
107
+ builtinToolFiltering: harnessV1BridgeBuiltinToolFilteringSchema.optional(),
93
108
  });
94
109
 
95
110
  // --- Transport / control frames (outbound, not consumer events) ---
@@ -10,6 +10,7 @@ import type {
10
10
  import type { HarnessV1Skill } from './harness-v1-skill';
11
11
  import type { HarnessV1StreamPart } from './harness-v1-stream-part';
12
12
  import type { HarnessV1ToolSpec } from './harness-v1-tool-spec';
13
+ import type { HarnessV1BuiltinToolFiltering } from './harness-v1-tool-filtering';
13
14
 
14
15
  /**
15
16
  * Options passed to `HarnessV1.doStart`.
@@ -53,6 +54,13 @@ export type HarnessV1StartOptions = {
53
54
  */
54
55
  readonly permissionMode?: HarnessV1PermissionMode;
55
56
 
57
+ /**
58
+ * Adapter-native built-in tools that should be available for this session.
59
+ * Custom host-executed tools are filtered by the framework before they reach
60
+ * the adapter.
61
+ */
62
+ readonly builtinToolFiltering?: HarnessV1BuiltinToolFiltering;
63
+
56
64
  /**
57
65
  * Signal that aborts startup. The adapter must propagate cancellation to
58
66
  * any spawned processes or network calls.
@@ -0,0 +1,25 @@
1
+ export type HarnessV1BuiltinToolFiltering =
2
+ | {
3
+ mode: 'allow';
4
+ toolNames: string[];
5
+ }
6
+ | {
7
+ mode: 'deny';
8
+ toolNames: string[];
9
+ };
10
+
11
+ export function isHarnessV1BuiltinToolIncluded(input: {
12
+ toolName: string;
13
+ toolFiltering: HarnessV1BuiltinToolFiltering | undefined;
14
+ }): boolean {
15
+ if (input.toolFiltering == null) return true;
16
+ return input.toolFiltering.mode === 'allow'
17
+ ? input.toolFiltering.toolNames.includes(input.toolName)
18
+ : !input.toolFiltering.toolNames.includes(input.toolName);
19
+ }
20
+
21
+ export function getHarnessV1BuiltinToolFilteringDenialReason(input: {
22
+ toolName: string;
23
+ }): string {
24
+ return `Tool '${input.toolName}' is inactive due to the HarnessAgent tool filtering policy.`;
25
+ }
@@ -51,6 +51,17 @@ export type HarnessV1<TBuiltinTools extends ToolSet = ToolSet> = {
51
51
  */
52
52
  readonly supportsBuiltinToolApprovals?: boolean;
53
53
 
54
+ /**
55
+ * Whether the adapter can prevent its underlying runtime from seeing or
56
+ * calling inactive built-in tools for every tool in `builtinTools`.
57
+ *
58
+ * Adapters without native filtering can still support `activeTools` and
59
+ * `inactiveTools` for built-ins when `supportsBuiltinToolApprovals` is
60
+ * `true`: the framework routes inactive built-in tool calls through the
61
+ * approval path and auto-denies them before they execute.
62
+ */
63
+ readonly supportsBuiltinToolFiltering?: boolean;
64
+
54
65
  /**
55
66
  * Optional schema for the adapter-defined `data` payload returned by session
56
67
  * lifecycle methods. When present, the adapter promises that exported state
package/src/v1/index.ts CHANGED
@@ -58,6 +58,7 @@ export {
58
58
  } from './harness-v1-stream-part';
59
59
  export {
60
60
  harnessV1BridgeAbortInboundSchema,
61
+ harnessV1BridgeBuiltinToolFilteringSchema,
61
62
  harnessV1BridgeDebugEventSchema,
62
63
  harnessV1BridgeDetachInboundSchema,
63
64
  harnessV1BridgeDetachSchema,
@@ -91,3 +92,8 @@ export {
91
92
  } from './harness-v1-diagnostic';
92
93
  export type { HarnessV1ToolSpec } from './harness-v1-tool-spec';
93
94
  export type { HarnessV1PermissionMode } from './harness-v1-permission-mode';
95
+ export type { HarnessV1BuiltinToolFiltering } from './harness-v1-tool-filtering';
96
+ export {
97
+ getHarnessV1BuiltinToolFilteringDenialReason,
98
+ isHarnessV1BuiltinToolIncluded,
99
+ } from './harness-v1-tool-filtering';