@ai-sdk/harness 1.0.100 → 1.0.102
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.
- package/CHANGELOG.md +30 -0
- package/agent/index.ts +2 -8
- package/dist/agent/index.d.ts +128 -48
- package/dist/agent/index.js +586 -512
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.d.ts +11 -1
- package/dist/bridge/index.js +37 -7
- package/dist/bridge/index.js.map +1 -1
- package/dist/index.d.ts +84 -2
- package/dist/index.js +286 -229
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +17 -3
- package/dist/utils/index.js +323 -19
- package/dist/utils/index.js.map +1 -1
- package/package.json +3 -3
- package/src/agent/harness-agent-session.ts +9 -8
- package/src/agent/harness-agent-settings.ts +82 -3
- package/src/agent/harness-agent-tool-approval-continuation.ts +6 -32
- package/src/agent/harness-agent-tool-result-continuation.ts +4 -53
- package/src/agent/harness-agent.ts +107 -18
- package/src/agent/internal/harness-stream-text-result.ts +33 -13
- package/src/agent/internal/run-prompt.ts +309 -122
- package/src/agent/internal/turn-telemetry.ts +293 -444
- package/src/bridge/index.ts +82 -11
- package/src/utils/index.ts +5 -0
- package/src/utils/write-instructions.ts +373 -0
- package/src/utils/write-skills.ts +59 -2
- package/src/v1/harness-v1-bridge-protocol.ts +1 -0
- package/src/v1/harness-v1-builtin-tool.ts +2 -0
- package/src/v1/harness-v1-lifecycle-state.ts +2 -0
- package/src/v1/harness-v1-prompt-control.ts +3 -0
- package/src/v1/harness-v1-questions-tool.ts +72 -0
- package/src/v1/harness-v1-session.ts +5 -0
- package/src/v1/index.ts +9 -0
package/src/bridge/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { appendFile, mkdir, writeFile } from 'node:fs/promises';
|
|
|
11
11
|
import { existsSync, readFileSync } from 'node:fs';
|
|
12
12
|
import { randomUUID } from 'node:crypto';
|
|
13
13
|
import { env as procEnv, pid, stdout } from 'node:process';
|
|
14
|
+
import type { ToolResultPart } from '@ai-sdk/provider-utils';
|
|
14
15
|
import { WebSocketServer, type WebSocket } from 'ws';
|
|
15
16
|
|
|
16
17
|
export { HarnessBridgeCapabilityUnsupportedError } from './harness-bridge-capability-unsupported-error';
|
|
@@ -244,8 +245,21 @@ export interface BridgeTurn {
|
|
|
244
245
|
* itself (via {@link emit}) using the same `toolCallId`.
|
|
245
246
|
*/
|
|
246
247
|
requestToolResult(
|
|
247
|
-
|
|
248
|
-
|
|
248
|
+
input:
|
|
249
|
+
| string
|
|
250
|
+
| {
|
|
251
|
+
toolCallId: string;
|
|
252
|
+
matches?: (result: {
|
|
253
|
+
output: unknown;
|
|
254
|
+
isError?: boolean;
|
|
255
|
+
toolResult?: ToolResultPart;
|
|
256
|
+
}) => boolean;
|
|
257
|
+
},
|
|
258
|
+
): Promise<{
|
|
259
|
+
output: unknown;
|
|
260
|
+
isError?: boolean;
|
|
261
|
+
toolResult?: ToolResultPart;
|
|
262
|
+
}>;
|
|
249
263
|
|
|
250
264
|
/**
|
|
251
265
|
* Register interest in a host approval decision and resolve when the matching
|
|
@@ -342,6 +356,7 @@ type InboundControl =
|
|
|
342
356
|
toolCallId: string;
|
|
343
357
|
output: unknown;
|
|
344
358
|
isError?: boolean;
|
|
359
|
+
toolResult?: ToolResultPart;
|
|
345
360
|
}
|
|
346
361
|
| {
|
|
347
362
|
type: 'tool-approval-response';
|
|
@@ -506,8 +521,27 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
506
521
|
|
|
507
522
|
const pendingToolResults = new Map<
|
|
508
523
|
string,
|
|
509
|
-
|
|
524
|
+
{
|
|
525
|
+
resolve: (output: {
|
|
526
|
+
output: unknown;
|
|
527
|
+
isError?: boolean;
|
|
528
|
+
toolResult?: ToolResultPart;
|
|
529
|
+
}) => void;
|
|
530
|
+
matches?: (output: {
|
|
531
|
+
output: unknown;
|
|
532
|
+
isError?: boolean;
|
|
533
|
+
toolResult?: ToolResultPart;
|
|
534
|
+
}) => boolean;
|
|
535
|
+
}
|
|
510
536
|
>();
|
|
537
|
+
const bufferedToolResults: Array<{
|
|
538
|
+
toolCallId: string;
|
|
539
|
+
result: {
|
|
540
|
+
output: unknown;
|
|
541
|
+
isError?: boolean;
|
|
542
|
+
toolResult?: ToolResultPart;
|
|
543
|
+
};
|
|
544
|
+
}> = [];
|
|
511
545
|
const pendingToolApprovals = new Map<
|
|
512
546
|
string,
|
|
513
547
|
(response: { approved: boolean; reason?: string }) => void
|
|
@@ -749,10 +783,28 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
749
783
|
const userMessages = createBridgeUserMessageQueue({ respond: emit });
|
|
750
784
|
const turn: BridgeTurn = {
|
|
751
785
|
emit,
|
|
752
|
-
requestToolResult:
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
786
|
+
requestToolResult: requestInput => {
|
|
787
|
+
const request =
|
|
788
|
+
typeof requestInput === 'string'
|
|
789
|
+
? { toolCallId: requestInput }
|
|
790
|
+
: requestInput;
|
|
791
|
+
const bufferedIndex = bufferedToolResults.findIndex(
|
|
792
|
+
buffered =>
|
|
793
|
+
buffered.toolCallId === request.toolCallId ||
|
|
794
|
+
request.matches?.(buffered.result) === true,
|
|
795
|
+
);
|
|
796
|
+
if (bufferedIndex >= 0) {
|
|
797
|
+
return Promise.resolve(
|
|
798
|
+
bufferedToolResults.splice(bufferedIndex, 1)[0].result,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
return new Promise(resolve => {
|
|
802
|
+
pendingToolResults.set(request.toolCallId, {
|
|
803
|
+
resolve,
|
|
804
|
+
matches: request.matches,
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
},
|
|
756
808
|
requestToolApproval: approvalId =>
|
|
757
809
|
new Promise(resolve => {
|
|
758
810
|
pendingToolApprovals.set(approvalId, resolve);
|
|
@@ -800,10 +852,29 @@ export async function runBridge<TStart extends { type: 'start' }>(
|
|
|
800
852
|
return;
|
|
801
853
|
}
|
|
802
854
|
case 'tool-result': {
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
855
|
+
const result = {
|
|
856
|
+
output: msg.output,
|
|
857
|
+
isError: msg.isError,
|
|
858
|
+
toolResult: msg.toolResult,
|
|
859
|
+
};
|
|
860
|
+
const exactPending = pendingToolResults.get(msg.toolCallId);
|
|
861
|
+
const matchingPending =
|
|
862
|
+
exactPending == null
|
|
863
|
+
? Array.from(pendingToolResults.entries()).find(
|
|
864
|
+
([, pending]) => pending.matches?.(result) === true,
|
|
865
|
+
)
|
|
866
|
+
: undefined;
|
|
867
|
+
const pending = exactPending ?? matchingPending?.[1];
|
|
868
|
+
const pendingId =
|
|
869
|
+
exactPending != null ? msg.toolCallId : matchingPending?.[0];
|
|
870
|
+
if (pending != null && pendingId != null) {
|
|
871
|
+
pendingToolResults.delete(pendingId);
|
|
872
|
+
pending.resolve(result);
|
|
873
|
+
} else {
|
|
874
|
+
bufferedToolResults.push({
|
|
875
|
+
toolCallId: msg.toolCallId,
|
|
876
|
+
result,
|
|
877
|
+
});
|
|
807
878
|
}
|
|
808
879
|
return;
|
|
809
880
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -26,6 +26,11 @@ export {
|
|
|
26
26
|
} from './sandbox-credential-brokering';
|
|
27
27
|
export { resolveSandboxHomeDir } from './sandbox-home-dir';
|
|
28
28
|
export { shellQuote } from './shell-quote';
|
|
29
|
+
export {
|
|
30
|
+
writeInstructions,
|
|
31
|
+
type WriteInstructionsOptions,
|
|
32
|
+
type WriteInstructionsResult,
|
|
33
|
+
} from './write-instructions';
|
|
29
34
|
export {
|
|
30
35
|
writeSkills,
|
|
31
36
|
type SkillFilePathMode,
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
safeParseJSON,
|
|
4
|
+
type Experimental_SandboxSession,
|
|
5
|
+
} from '@ai-sdk/provider-utils';
|
|
6
|
+
import { shellQuote } from './shell-quote';
|
|
7
|
+
|
|
8
|
+
const INSTRUCTIONS_METADATA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
export type WriteInstructionsOptions = {
|
|
11
|
+
sandbox: Experimental_SandboxSession;
|
|
12
|
+
homePath: string;
|
|
13
|
+
instructionsFile: string;
|
|
14
|
+
instructions?: string;
|
|
15
|
+
abortSignal?: AbortSignal;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type WriteInstructionsResult = {
|
|
19
|
+
changed: boolean;
|
|
20
|
+
filePath: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type InstructionsMetadata = {
|
|
24
|
+
readonly version: typeof INSTRUCTIONS_METADATA_VERSION;
|
|
25
|
+
readonly originalContent: string | null;
|
|
26
|
+
readonly instructions: string;
|
|
27
|
+
readonly appliedContent: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function writeInstructions({
|
|
31
|
+
sandbox,
|
|
32
|
+
homePath,
|
|
33
|
+
instructionsFile,
|
|
34
|
+
instructions,
|
|
35
|
+
abortSignal,
|
|
36
|
+
}: WriteInstructionsOptions): Promise<WriteInstructionsResult> {
|
|
37
|
+
const { targetPath, metadataPath } = resolveInstructionsFilePath({
|
|
38
|
+
homePath,
|
|
39
|
+
instructionsFile,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const hasInstructions =
|
|
43
|
+
typeof instructions === 'string' && instructions.trim().length > 0;
|
|
44
|
+
const trimmedInstructions = hasInstructions ? instructions.trim() : '';
|
|
45
|
+
|
|
46
|
+
const currentDiskContent = await sandbox.readTextFile({
|
|
47
|
+
path: targetPath,
|
|
48
|
+
abortSignal,
|
|
49
|
+
});
|
|
50
|
+
const existingMetadata = await readInstructionsMetadata({
|
|
51
|
+
sandbox,
|
|
52
|
+
metadataPath,
|
|
53
|
+
abortSignal,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (hasInstructions) {
|
|
57
|
+
const originalContent = deriveOriginalContent({
|
|
58
|
+
currentDiskContent,
|
|
59
|
+
existingMetadata,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const targetContent =
|
|
63
|
+
originalContent != null && originalContent.trim().length > 0
|
|
64
|
+
? `${originalContent.replace(/\n+$/, '')}\n\n${trimmedInstructions}\n`
|
|
65
|
+
: `${trimmedInstructions}\n`;
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
currentDiskContent === targetContent &&
|
|
69
|
+
existingMetadata != null &&
|
|
70
|
+
existingMetadata.instructions === trimmedInstructions &&
|
|
71
|
+
existingMetadata.originalContent === originalContent
|
|
72
|
+
) {
|
|
73
|
+
return { changed: false, filePath: targetPath };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await sandbox.writeTextFile({
|
|
77
|
+
path: targetPath,
|
|
78
|
+
content: targetContent,
|
|
79
|
+
abortSignal,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await writeInstructionsMetadata({
|
|
83
|
+
sandbox,
|
|
84
|
+
metadataPath,
|
|
85
|
+
metadata: {
|
|
86
|
+
version: INSTRUCTIONS_METADATA_VERSION,
|
|
87
|
+
originalContent,
|
|
88
|
+
instructions: trimmedInstructions,
|
|
89
|
+
appliedContent: targetContent,
|
|
90
|
+
},
|
|
91
|
+
abortSignal,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { changed: true, filePath: targetPath };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (existingMetadata == null) {
|
|
98
|
+
return { changed: false, filePath: targetPath };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const restoredContent = deriveRestoredContent({
|
|
102
|
+
currentDiskContent,
|
|
103
|
+
existingMetadata,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (restoredContent != null) {
|
|
107
|
+
const contentToWrite = `${restoredContent.replace(/\n+$/, '')}\n`;
|
|
108
|
+
await sandbox.writeTextFile({
|
|
109
|
+
path: targetPath,
|
|
110
|
+
content: contentToWrite,
|
|
111
|
+
abortSignal,
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
await removeTargetFile({
|
|
115
|
+
sandbox,
|
|
116
|
+
targetPath,
|
|
117
|
+
abortSignal,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await removeMetadataFile({
|
|
122
|
+
sandbox,
|
|
123
|
+
metadataPath,
|
|
124
|
+
abortSignal,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return { changed: true, filePath: targetPath };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function deriveOriginalContent({
|
|
131
|
+
currentDiskContent,
|
|
132
|
+
existingMetadata,
|
|
133
|
+
}: {
|
|
134
|
+
currentDiskContent: string | null;
|
|
135
|
+
existingMetadata: InstructionsMetadata | undefined;
|
|
136
|
+
}): string | null {
|
|
137
|
+
if (existingMetadata == null) {
|
|
138
|
+
return currentDiskContent;
|
|
139
|
+
}
|
|
140
|
+
if (currentDiskContent == null) {
|
|
141
|
+
return existingMetadata.originalContent;
|
|
142
|
+
}
|
|
143
|
+
if (currentDiskContent === existingMetadata.appliedContent) {
|
|
144
|
+
return existingMetadata.originalContent;
|
|
145
|
+
}
|
|
146
|
+
const trimmedDisk = currentDiskContent.replace(/\n+$/, '');
|
|
147
|
+
const expectedSuffix = `\n\n${existingMetadata.instructions}`;
|
|
148
|
+
if (trimmedDisk.endsWith(expectedSuffix)) {
|
|
149
|
+
const userBase = trimmedDisk.slice(0, -expectedSuffix.length);
|
|
150
|
+
return userBase.length > 0 ? userBase : null;
|
|
151
|
+
}
|
|
152
|
+
if (trimmedDisk === existingMetadata.instructions) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return existingMetadata.originalContent ?? currentDiskContent;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function deriveRestoredContent({
|
|
159
|
+
currentDiskContent,
|
|
160
|
+
existingMetadata,
|
|
161
|
+
}: {
|
|
162
|
+
currentDiskContent: string | null;
|
|
163
|
+
existingMetadata: InstructionsMetadata;
|
|
164
|
+
}): string | null {
|
|
165
|
+
if (currentDiskContent == null) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (currentDiskContent === existingMetadata.appliedContent) {
|
|
169
|
+
return existingMetadata.originalContent != null &&
|
|
170
|
+
existingMetadata.originalContent.trim().length > 0
|
|
171
|
+
? existingMetadata.originalContent
|
|
172
|
+
: null;
|
|
173
|
+
}
|
|
174
|
+
const trimmedDisk = currentDiskContent.replace(/\n+$/, '');
|
|
175
|
+
const expectedSuffix = `\n\n${existingMetadata.instructions}`;
|
|
176
|
+
if (trimmedDisk.endsWith(expectedSuffix)) {
|
|
177
|
+
const userBase = trimmedDisk.slice(0, -expectedSuffix.length);
|
|
178
|
+
return userBase.length > 0 ? userBase : null;
|
|
179
|
+
}
|
|
180
|
+
if (trimmedDisk === existingMetadata.instructions) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
return currentDiskContent;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveInstructionsFilePath({
|
|
187
|
+
homePath,
|
|
188
|
+
instructionsFile,
|
|
189
|
+
}: {
|
|
190
|
+
homePath: string;
|
|
191
|
+
instructionsFile: string;
|
|
192
|
+
}): { targetPath: string; metadataPath: string } {
|
|
193
|
+
if (typeof homePath !== 'string' || homePath.trim().length === 0) {
|
|
194
|
+
throw new Error('Invalid homePath: expected a non-empty string.');
|
|
195
|
+
}
|
|
196
|
+
if (!path.posix.isAbsolute(homePath)) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`Invalid homePath ${JSON.stringify(homePath)}: expected an absolute POSIX path.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
typeof instructionsFile !== 'string' ||
|
|
203
|
+
instructionsFile.trim().length === 0
|
|
204
|
+
) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Invalid instructionsFile ${JSON.stringify(instructionsFile)}: expected a relative POSIX path without traversal.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const containsTraversal = instructionsFile
|
|
210
|
+
.split(/[\\\/]/)
|
|
211
|
+
.some(segment => segment === '..');
|
|
212
|
+
const normalizedInstructionsFile = path.posix.normalize(
|
|
213
|
+
instructionsFile.trim(),
|
|
214
|
+
);
|
|
215
|
+
const normalizedNoTrailingSlash = normalizedInstructionsFile.replace(
|
|
216
|
+
/\/+$/,
|
|
217
|
+
'',
|
|
218
|
+
);
|
|
219
|
+
if (
|
|
220
|
+
instructionsFile.includes('\\') ||
|
|
221
|
+
path.posix.isAbsolute(instructionsFile) ||
|
|
222
|
+
path.win32.isAbsolute(instructionsFile) ||
|
|
223
|
+
containsTraversal ||
|
|
224
|
+
instructionsFile.endsWith('/') ||
|
|
225
|
+
instructionsFile.endsWith('\\') ||
|
|
226
|
+
instructionsFile.endsWith('/.') ||
|
|
227
|
+
instructionsFile.endsWith('/..') ||
|
|
228
|
+
normalizedNoTrailingSlash === '' ||
|
|
229
|
+
normalizedNoTrailingSlash === '.' ||
|
|
230
|
+
normalizedInstructionsFile.startsWith('../') ||
|
|
231
|
+
normalizedInstructionsFile.includes('/../') ||
|
|
232
|
+
normalizedInstructionsFile.endsWith('/..')
|
|
233
|
+
) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Invalid instructionsFile ${JSON.stringify(instructionsFile)}: expected a relative POSIX path without traversal.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const targetPath = path.posix.join(homePath, normalizedNoTrailingSlash);
|
|
239
|
+
const relative = path.posix.relative(homePath, targetPath);
|
|
240
|
+
if (
|
|
241
|
+
relative === '' ||
|
|
242
|
+
relative.startsWith('..') ||
|
|
243
|
+
path.posix.isAbsolute(relative)
|
|
244
|
+
) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Invalid instructionsFile ${JSON.stringify(instructionsFile)}: must be a subpath within homePath ${JSON.stringify(homePath)}.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const dir = path.posix.dirname(targetPath);
|
|
251
|
+
const base = path.posix.basename(targetPath);
|
|
252
|
+
const metadataPath = path.posix.join(
|
|
253
|
+
dir,
|
|
254
|
+
`.${base}.ai-sdk-harness-instructions.json`,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return { targetPath, metadataPath };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function readInstructionsMetadata({
|
|
261
|
+
sandbox,
|
|
262
|
+
metadataPath,
|
|
263
|
+
abortSignal,
|
|
264
|
+
}: {
|
|
265
|
+
sandbox: Experimental_SandboxSession;
|
|
266
|
+
metadataPath: string;
|
|
267
|
+
abortSignal?: AbortSignal;
|
|
268
|
+
}): Promise<InstructionsMetadata | undefined> {
|
|
269
|
+
const content = await sandbox.readTextFile({
|
|
270
|
+
path: metadataPath,
|
|
271
|
+
abortSignal,
|
|
272
|
+
});
|
|
273
|
+
if (content == null) return undefined;
|
|
274
|
+
const parsed = await safeParseJSON({ text: content });
|
|
275
|
+
if (!parsed.success || !isInstructionsMetadata(parsed.value)) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Invalid AI SDK harness instructions metadata: ${metadataPath}`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return parsed.value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isInstructionsMetadata(value: unknown): value is InstructionsMetadata {
|
|
284
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
const candidate = value as Record<string, unknown>;
|
|
288
|
+
return (
|
|
289
|
+
candidate.version === INSTRUCTIONS_METADATA_VERSION &&
|
|
290
|
+
(candidate.originalContent === null ||
|
|
291
|
+
typeof candidate.originalContent === 'string') &&
|
|
292
|
+
typeof candidate.instructions === 'string' &&
|
|
293
|
+
typeof candidate.appliedContent === 'string'
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function writeInstructionsMetadata({
|
|
298
|
+
sandbox,
|
|
299
|
+
metadataPath,
|
|
300
|
+
metadata,
|
|
301
|
+
abortSignal,
|
|
302
|
+
}: {
|
|
303
|
+
sandbox: Experimental_SandboxSession;
|
|
304
|
+
metadataPath: string;
|
|
305
|
+
metadata: InstructionsMetadata;
|
|
306
|
+
abortSignal?: AbortSignal;
|
|
307
|
+
}): Promise<void> {
|
|
308
|
+
const temporaryPath = `${metadataPath}.tmp`;
|
|
309
|
+
await sandbox.writeTextFile({
|
|
310
|
+
path: temporaryPath,
|
|
311
|
+
content: `${JSON.stringify(metadata, null, 2)}\n`,
|
|
312
|
+
abortSignal,
|
|
313
|
+
});
|
|
314
|
+
await runSandboxCommand({
|
|
315
|
+
sandbox,
|
|
316
|
+
command: `mv -f ${shellQuote(temporaryPath)} ${shellQuote(metadataPath)}`,
|
|
317
|
+
abortSignal,
|
|
318
|
+
errorMessage: `Failed to update instructions metadata: ${metadataPath}`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function removeMetadataFile({
|
|
323
|
+
sandbox,
|
|
324
|
+
metadataPath,
|
|
325
|
+
abortSignal,
|
|
326
|
+
}: {
|
|
327
|
+
sandbox: Experimental_SandboxSession;
|
|
328
|
+
metadataPath: string;
|
|
329
|
+
abortSignal?: AbortSignal;
|
|
330
|
+
}): Promise<void> {
|
|
331
|
+
await runSandboxCommand({
|
|
332
|
+
sandbox,
|
|
333
|
+
command: `rm -f -- ${shellQuote(metadataPath)}`,
|
|
334
|
+
abortSignal,
|
|
335
|
+
errorMessage: `Failed to remove instructions metadata: ${metadataPath}`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function removeTargetFile({
|
|
340
|
+
sandbox,
|
|
341
|
+
targetPath,
|
|
342
|
+
abortSignal,
|
|
343
|
+
}: {
|
|
344
|
+
sandbox: Experimental_SandboxSession;
|
|
345
|
+
targetPath: string;
|
|
346
|
+
abortSignal?: AbortSignal;
|
|
347
|
+
}): Promise<void> {
|
|
348
|
+
await runSandboxCommand({
|
|
349
|
+
sandbox,
|
|
350
|
+
command: `rm -f -- ${shellQuote(targetPath)}`,
|
|
351
|
+
abortSignal,
|
|
352
|
+
errorMessage: `Failed to remove instructions file: ${targetPath}`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function runSandboxCommand({
|
|
357
|
+
sandbox,
|
|
358
|
+
command,
|
|
359
|
+
abortSignal,
|
|
360
|
+
errorMessage,
|
|
361
|
+
}: {
|
|
362
|
+
sandbox: Experimental_SandboxSession;
|
|
363
|
+
command: string;
|
|
364
|
+
abortSignal?: AbortSignal;
|
|
365
|
+
errorMessage: string;
|
|
366
|
+
}): Promise<void> {
|
|
367
|
+
const result = await sandbox.run({ command, abortSignal });
|
|
368
|
+
if (result.exitCode !== 0) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`${errorMessage} (exit ${result.exitCode})${result.stderr ? `: ${result.stderr}` : ''}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -15,7 +15,8 @@ export type SkillFilePathMode = 'relative' | 'strip-leading-slashes';
|
|
|
15
15
|
|
|
16
16
|
export type WriteSkillsOptions = {
|
|
17
17
|
sandbox: Experimental_SandboxSession;
|
|
18
|
-
|
|
18
|
+
homePath: string;
|
|
19
|
+
skillsDir: string;
|
|
19
20
|
skills: ReadonlyArray<HarnessV1Skill>;
|
|
20
21
|
abortSignal?: AbortSignal;
|
|
21
22
|
skillNamePattern?: RegExp;
|
|
@@ -57,7 +58,8 @@ type SkillsManifest = {
|
|
|
57
58
|
|
|
58
59
|
export async function writeSkills({
|
|
59
60
|
sandbox,
|
|
60
|
-
|
|
61
|
+
homePath,
|
|
62
|
+
skillsDir,
|
|
61
63
|
skills,
|
|
62
64
|
abortSignal,
|
|
63
65
|
skillNamePattern = /^[A-Za-z0-9._-]+$/,
|
|
@@ -67,6 +69,7 @@ export async function writeSkills({
|
|
|
67
69
|
`Invalid skill file path for ${skillName}: ${filePath}`,
|
|
68
70
|
trailingNewline = false,
|
|
69
71
|
}: WriteSkillsOptions): Promise<WriteSkillsResult> {
|
|
72
|
+
const rootDir = resolveSkillsRootDir({ homePath, skillsDir });
|
|
70
73
|
const projectedSkills = skills
|
|
71
74
|
.map(skill =>
|
|
72
75
|
projectSkill({
|
|
@@ -526,3 +529,57 @@ function renderSkillFile({
|
|
|
526
529
|
const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
|
|
527
530
|
return trailingNewline ? `${content}\n` : content;
|
|
528
531
|
}
|
|
532
|
+
|
|
533
|
+
function resolveSkillsRootDir({
|
|
534
|
+
homePath,
|
|
535
|
+
skillsDir,
|
|
536
|
+
}: {
|
|
537
|
+
homePath: string;
|
|
538
|
+
skillsDir: string;
|
|
539
|
+
}): string {
|
|
540
|
+
if (typeof homePath !== 'string' || homePath.trim().length === 0) {
|
|
541
|
+
throw new Error('Invalid homePath: expected a non-empty string.');
|
|
542
|
+
}
|
|
543
|
+
if (!path.posix.isAbsolute(homePath)) {
|
|
544
|
+
throw new Error(
|
|
545
|
+
`Invalid homePath ${JSON.stringify(homePath)}: expected an absolute POSIX path.`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
if (typeof skillsDir !== 'string' || skillsDir.trim().length === 0) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`Invalid skillsDir ${JSON.stringify(skillsDir)}: expected a relative POSIX path without traversal.`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const containsTraversal = skillsDir
|
|
554
|
+
.split(/[\\\/]/)
|
|
555
|
+
.some(segment => segment === '..');
|
|
556
|
+
const normalizedSkillsDir = path.posix.normalize(skillsDir.trim());
|
|
557
|
+
const normalizedNoTrailingSlash = normalizedSkillsDir.replace(/\/+$/, '');
|
|
558
|
+
if (
|
|
559
|
+
skillsDir.includes('\\') ||
|
|
560
|
+
path.posix.isAbsolute(skillsDir) ||
|
|
561
|
+
path.win32.isAbsolute(skillsDir) ||
|
|
562
|
+
containsTraversal ||
|
|
563
|
+
normalizedNoTrailingSlash === '' ||
|
|
564
|
+
normalizedNoTrailingSlash === '.' ||
|
|
565
|
+
normalizedSkillsDir.startsWith('../') ||
|
|
566
|
+
normalizedSkillsDir.includes('/../') ||
|
|
567
|
+
normalizedSkillsDir.endsWith('/..')
|
|
568
|
+
) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`Invalid skillsDir ${JSON.stringify(skillsDir)}: expected a relative POSIX path without traversal.`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
const rootDir = path.posix.join(homePath, normalizedNoTrailingSlash);
|
|
574
|
+
const relative = path.posix.relative(homePath, rootDir);
|
|
575
|
+
if (
|
|
576
|
+
relative === '' ||
|
|
577
|
+
relative.startsWith('..') ||
|
|
578
|
+
path.posix.isAbsolute(relative)
|
|
579
|
+
) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`Invalid skillsDir ${JSON.stringify(skillsDir)}: must be a subpath within homePath ${JSON.stringify(homePath)}.`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return rootDir;
|
|
585
|
+
}
|
|
@@ -295,6 +295,7 @@ export const harnessV1BridgeToolResultInboundSchema = z.object({
|
|
|
295
295
|
toolCallId: z.string(),
|
|
296
296
|
output: z.unknown(),
|
|
297
297
|
isError: z.boolean().optional(),
|
|
298
|
+
toolResult: z.unknown().optional(),
|
|
298
299
|
});
|
|
299
300
|
|
|
300
301
|
export const harnessV1BridgeToolApprovalResponseInboundSchema = z.object({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool, type FlexibleSchema, type Tool } from '@ai-sdk/provider-utils';
|
|
2
2
|
import { z } from 'zod/v4';
|
|
3
|
+
import { harnessV1QuestionsTool } from './harness-v1-questions-tool';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Cross-harness vocabulary of common built-in tool names with their baseline
|
|
@@ -50,6 +51,7 @@ export const HARNESS_V1_BUILTIN_TOOLS = {
|
|
|
50
51
|
inputSchema: z.object({ query: z.string() }),
|
|
51
52
|
outputSchema: z.unknown(),
|
|
52
53
|
}),
|
|
54
|
+
askUserQuestions: harnessV1QuestionsTool,
|
|
53
55
|
} as const;
|
|
54
56
|
|
|
55
57
|
export type HarnessV1BuiltinToolName = keyof typeof HARNESS_V1_BUILTIN_TOOLS;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JSONValue } from '@ai-sdk/provider';
|
|
2
|
+
import type { ProviderOptions } from '@ai-sdk/provider-utils';
|
|
2
3
|
import type { HarnessV1Skill } from './harness-v1-skill';
|
|
3
4
|
import type { HarnessV1ToolSpec } from './harness-v1-tool-spec';
|
|
4
5
|
|
|
@@ -16,6 +17,7 @@ export type HarnessV1PendingToolResult = {
|
|
|
16
17
|
readonly toolCallId: string;
|
|
17
18
|
readonly toolName: string;
|
|
18
19
|
readonly input: string;
|
|
20
|
+
readonly providerOptions?: ProviderOptions;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ToolResultPart } from '@ai-sdk/provider-utils';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Bidirectional control surface returned by `doPromptTurn`.
|
|
3
5
|
*
|
|
@@ -16,6 +18,7 @@ export type HarnessV1PromptControl = {
|
|
|
16
18
|
toolCallId: string;
|
|
17
19
|
output: unknown;
|
|
18
20
|
isError?: boolean;
|
|
21
|
+
toolResult?: ToolResultPart;
|
|
19
22
|
}): PromiseLike<void>;
|
|
20
23
|
|
|
21
24
|
/**
|