@ai-sdk/harness 0.0.0-6b196531-20260710185421
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 +414 -0
- package/LICENSE +13 -0
- package/README.md +176 -0
- package/agent/index.ts +56 -0
- package/bridge/index.ts +10 -0
- package/dist/agent/index.d.ts +1631 -0
- package/dist/agent/index.js +3491 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/bridge/index.d.ts +129 -0
- package/dist/bridge/index.js +482 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/index.d.ts +1587 -0
- package/dist/index.js +517 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/index.d.ts +329 -0
- package/dist/utils/index.js +1241 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +100 -0
- package/src/agent/harness-agent-session.ts +518 -0
- package/src/agent/harness-agent-settings.ts +187 -0
- package/src/agent/harness-agent-tool-approval-continuation.ts +94 -0
- package/src/agent/harness-agent-tool-types.ts +15 -0
- package/src/agent/harness-agent-types.ts +50 -0
- package/src/agent/harness-agent.ts +865 -0
- package/src/agent/internal/bootstrap-recipe.ts +124 -0
- package/src/agent/internal/bridge-port-registry.ts +52 -0
- package/src/agent/internal/harness-stream-text-result.ts +731 -0
- package/src/agent/internal/lifecycle-state-validation.ts +95 -0
- package/src/agent/internal/permission-mode.ts +50 -0
- package/src/agent/internal/resolve-observability.ts +128 -0
- package/src/agent/internal/run-prompt.ts +901 -0
- package/src/agent/internal/sandbox-bootstrap.ts +266 -0
- package/src/agent/internal/strip-work-dir.ts +68 -0
- package/src/agent/internal/to-harness-stream.ts +75 -0
- package/src/agent/internal/tool-filtering.ts +114 -0
- package/src/agent/internal/translate-stream-part.ts +221 -0
- package/src/agent/internal/turn-telemetry.ts +361 -0
- package/src/agent/observability/file-reporter.ts +206 -0
- package/src/agent/observability/index.ts +15 -0
- package/src/agent/observability/trace-tree-reporter.ts +122 -0
- package/src/agent/observability/types.ts +86 -0
- package/src/agent/prepare-harness-sandbox-template.ts +68 -0
- package/src/agent/prepare-sandbox-for-harness.ts +165 -0
- package/src/bridge/index.ts +797 -0
- package/src/errors/harness-capability-unsupported-error.ts +41 -0
- package/src/errors/harness-error.ts +22 -0
- package/src/index.ts +3 -0
- package/src/utils/ai-gateway-auth.ts +15 -0
- package/src/utils/bridge-diagnostics.ts +213 -0
- package/src/utils/bridge-ready.ts +277 -0
- package/src/utils/classify-disk-log.ts +43 -0
- package/src/utils/index.ts +31 -0
- package/src/utils/sandbox-channel.ts +525 -0
- package/src/utils/sandbox-home-dir.ts +22 -0
- package/src/utils/shell-quote.ts +3 -0
- package/src/utils/write-skills.ts +141 -0
- package/src/v1/harness-v1-bootstrap.ts +46 -0
- package/src/v1/harness-v1-bridge-protocol.ts +342 -0
- package/src/v1/harness-v1-builtin-tool.ts +138 -0
- package/src/v1/harness-v1-call-warning.ts +22 -0
- package/src/v1/harness-v1-diagnostic.ts +66 -0
- package/src/v1/harness-v1-lifecycle-state.ts +65 -0
- package/src/v1/harness-v1-metadata.ts +13 -0
- package/src/v1/harness-v1-network-sandbox-session.ts +123 -0
- package/src/v1/harness-v1-observability.ts +20 -0
- package/src/v1/harness-v1-permission-mode.ts +11 -0
- package/src/v1/harness-v1-prompt-control.ts +41 -0
- package/src/v1/harness-v1-prompt.ts +11 -0
- package/src/v1/harness-v1-sandbox-provider.ts +76 -0
- package/src/v1/harness-v1-session.ts +280 -0
- package/src/v1/harness-v1-skill.ts +36 -0
- package/src/v1/harness-v1-stream-part.ts +363 -0
- package/src/v1/harness-v1-tool-filtering.ts +25 -0
- package/src/v1/harness-v1-tool-spec.ts +31 -0
- package/src/v1/harness-v1.ts +94 -0
- package/src/v1/index.ts +99 -0
- package/utils/index.ts +1 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
3
|
+
import type { HarnessV1Bootstrap } from '../../v1';
|
|
4
|
+
import type { HarnessAgentSandboxConfig } from '../harness-agent-settings';
|
|
5
|
+
import { applyBootstrapRecipe, hashHarnessBootstrap } from './bootstrap-recipe';
|
|
6
|
+
|
|
7
|
+
const SANDBOX_BOOTSTRAP_IDENTITY_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
type SandboxBootstrapSettings = Omit<HarnessAgentSandboxConfig, 'onSession'>;
|
|
10
|
+
|
|
11
|
+
export type SandboxBootstrapPlan = {
|
|
12
|
+
readonly recipe?: HarnessV1Bootstrap;
|
|
13
|
+
readonly recipeIdentity?: string;
|
|
14
|
+
readonly identity?: string;
|
|
15
|
+
readonly workDir?: string;
|
|
16
|
+
readonly onFirstCreate?: (
|
|
17
|
+
session: SandboxSession,
|
|
18
|
+
opts: { abortSignal?: AbortSignal },
|
|
19
|
+
) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function validateSandboxBootstrapSettings(
|
|
23
|
+
settings: SandboxBootstrapSettings,
|
|
24
|
+
): void {
|
|
25
|
+
if ((settings.onBootstrap == null) !== (settings.bootstrapHash == null)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'HarnessAgent: `sandboxConfig.onBootstrap` and `sandboxConfig.bootstrapHash` must be provided together.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (settings.workDir != null) {
|
|
32
|
+
normalizeSandboxWorkDir(settings.workDir);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeSandboxWorkDir(workDir: string): string {
|
|
37
|
+
if (workDir.length === 0) {
|
|
38
|
+
throw new Error('HarnessAgent: `sandboxConfig.workDir` must not be empty.');
|
|
39
|
+
}
|
|
40
|
+
if (workDir.includes('\0')) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'HarnessAgent: `sandboxConfig.workDir` must not contain NUL.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (workDir.includes('\\')) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'HarnessAgent: `sandboxConfig.workDir` must use POSIX path separators.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (posix.isAbsolute(workDir)) {
|
|
51
|
+
throw new Error('HarnessAgent: `sandboxConfig.workDir` must be relative.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalized = posix.normalize(workDir);
|
|
55
|
+
if (
|
|
56
|
+
normalized === '.' ||
|
|
57
|
+
normalized === '..' ||
|
|
58
|
+
normalized.startsWith('../')
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'HarnessAgent: `sandboxConfig.workDir` must stay inside the sandbox default working directory.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveSessionWorkDir({
|
|
68
|
+
defaultWorkingDirectory,
|
|
69
|
+
harnessId,
|
|
70
|
+
sessionId,
|
|
71
|
+
workDir,
|
|
72
|
+
}: {
|
|
73
|
+
readonly defaultWorkingDirectory: string;
|
|
74
|
+
readonly harnessId: string;
|
|
75
|
+
readonly sessionId: string;
|
|
76
|
+
readonly workDir?: string;
|
|
77
|
+
}): string {
|
|
78
|
+
return joinSandboxPath({
|
|
79
|
+
base: defaultWorkingDirectory,
|
|
80
|
+
path: workDir ?? `${harnessId}-${sessionId}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function createSandboxBootstrapPlan({
|
|
85
|
+
recipe,
|
|
86
|
+
settings,
|
|
87
|
+
}: {
|
|
88
|
+
readonly recipe?: HarnessV1Bootstrap;
|
|
89
|
+
readonly settings: SandboxBootstrapSettings;
|
|
90
|
+
}): Promise<SandboxBootstrapPlan> {
|
|
91
|
+
const workDir =
|
|
92
|
+
settings.workDir == null
|
|
93
|
+
? undefined
|
|
94
|
+
: normalizeSandboxWorkDir(settings.workDir);
|
|
95
|
+
const recipeIdentity =
|
|
96
|
+
recipe == null ? undefined : await hashHarnessBootstrap(recipe);
|
|
97
|
+
const hasCallerBootstrap = settings.onBootstrap != null;
|
|
98
|
+
const needsCombinedIdentity =
|
|
99
|
+
hasCallerBootstrap || (recipeIdentity != null && workDir != null);
|
|
100
|
+
const identity =
|
|
101
|
+
needsCombinedIdentity && (recipeIdentity != null || hasCallerBootstrap)
|
|
102
|
+
? await hashSandboxBootstrapIdentity({
|
|
103
|
+
recipeIdentity,
|
|
104
|
+
bootstrapHash: settings.bootstrapHash,
|
|
105
|
+
workDir,
|
|
106
|
+
})
|
|
107
|
+
: recipeIdentity;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...(recipe != null ? { recipe } : {}),
|
|
111
|
+
...(recipeIdentity != null ? { recipeIdentity } : {}),
|
|
112
|
+
...(identity != null ? { identity } : {}),
|
|
113
|
+
...(workDir != null ? { workDir } : {}),
|
|
114
|
+
...(recipe != null || settings.onBootstrap != null
|
|
115
|
+
? {
|
|
116
|
+
onFirstCreate: (session, opts) =>
|
|
117
|
+
runSandboxBootstrap({
|
|
118
|
+
session,
|
|
119
|
+
recipe,
|
|
120
|
+
recipeIdentity,
|
|
121
|
+
workDir,
|
|
122
|
+
onBootstrap: settings.onBootstrap,
|
|
123
|
+
abortSignal: opts.abortSignal,
|
|
124
|
+
}),
|
|
125
|
+
}
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runSandboxBootstrap({
|
|
131
|
+
session,
|
|
132
|
+
recipe,
|
|
133
|
+
recipeIdentity,
|
|
134
|
+
workDir,
|
|
135
|
+
onBootstrap,
|
|
136
|
+
abortSignal,
|
|
137
|
+
}: {
|
|
138
|
+
readonly session: SandboxSession;
|
|
139
|
+
readonly recipe?: HarnessV1Bootstrap;
|
|
140
|
+
readonly recipeIdentity?: string;
|
|
141
|
+
readonly workDir?: string;
|
|
142
|
+
readonly onBootstrap?: SandboxBootstrapSettings['onBootstrap'];
|
|
143
|
+
readonly abortSignal?: AbortSignal;
|
|
144
|
+
}): Promise<void> {
|
|
145
|
+
if (recipe != null && recipeIdentity != null) {
|
|
146
|
+
await applyBootstrapRecipe(session, recipe, recipeIdentity, {
|
|
147
|
+
abortSignal,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (onBootstrap == null) return;
|
|
152
|
+
|
|
153
|
+
const defaultWorkingDirectory = await resolveDefaultWorkingDirectory({
|
|
154
|
+
session,
|
|
155
|
+
abortSignal,
|
|
156
|
+
});
|
|
157
|
+
const bootstrapWorkDir =
|
|
158
|
+
workDir == null
|
|
159
|
+
? defaultWorkingDirectory
|
|
160
|
+
: joinSandboxPath({
|
|
161
|
+
base: defaultWorkingDirectory,
|
|
162
|
+
path: workDir,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ensureSandboxDirectory({
|
|
166
|
+
session,
|
|
167
|
+
workDir: bootstrapWorkDir,
|
|
168
|
+
abortSignal,
|
|
169
|
+
});
|
|
170
|
+
await onBootstrap({ session, workDir: bootstrapWorkDir, abortSignal });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function resolveDefaultWorkingDirectory({
|
|
174
|
+
session,
|
|
175
|
+
abortSignal,
|
|
176
|
+
}: {
|
|
177
|
+
readonly session: SandboxSession;
|
|
178
|
+
readonly abortSignal?: AbortSignal;
|
|
179
|
+
}): Promise<string> {
|
|
180
|
+
const result = await session.run({
|
|
181
|
+
command: 'pwd',
|
|
182
|
+
abortSignal,
|
|
183
|
+
});
|
|
184
|
+
if (result.exitCode !== 0) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Failed to resolve sandbox default working directory (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cwd = result.stdout.trim();
|
|
191
|
+
if (!posix.isAbsolute(cwd)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Failed to resolve sandbox default working directory: expected an absolute path, got ${JSON.stringify(cwd)}.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return cwd === '/' ? cwd : cwd.replace(/\/+$/, '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function ensureSandboxDirectory({
|
|
200
|
+
session,
|
|
201
|
+
workDir,
|
|
202
|
+
abortSignal,
|
|
203
|
+
}: {
|
|
204
|
+
readonly session: SandboxSession;
|
|
205
|
+
readonly workDir: string;
|
|
206
|
+
readonly abortSignal?: AbortSignal;
|
|
207
|
+
}): Promise<void> {
|
|
208
|
+
const result = await session.run({
|
|
209
|
+
command: 'mkdir -p "$WORK_DIR"',
|
|
210
|
+
env: { WORK_DIR: workDir },
|
|
211
|
+
abortSignal,
|
|
212
|
+
});
|
|
213
|
+
if (result.exitCode !== 0) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Failed to create sandbox work directory ${workDir} (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function hashSandboxBootstrapIdentity({
|
|
221
|
+
recipeIdentity,
|
|
222
|
+
bootstrapHash,
|
|
223
|
+
workDir,
|
|
224
|
+
}: {
|
|
225
|
+
readonly recipeIdentity?: string;
|
|
226
|
+
readonly bootstrapHash?: string;
|
|
227
|
+
readonly workDir?: string;
|
|
228
|
+
}): Promise<string> {
|
|
229
|
+
const encoder = new TextEncoder();
|
|
230
|
+
const chunks: Uint8Array[] = [];
|
|
231
|
+
const pushString = (value: string) => {
|
|
232
|
+
chunks.push(encoder.encode(value));
|
|
233
|
+
chunks.push(encoder.encode('\0'));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
pushString(String(SANDBOX_BOOTSTRAP_IDENTITY_VERSION));
|
|
237
|
+
pushString(recipeIdentity ?? '');
|
|
238
|
+
pushString(bootstrapHash ?? '');
|
|
239
|
+
pushString(workDir ?? '');
|
|
240
|
+
|
|
241
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
242
|
+
const buffer = new Uint8Array(totalLength);
|
|
243
|
+
let offset = 0;
|
|
244
|
+
for (const chunk of chunks) {
|
|
245
|
+
buffer.set(chunk, offset);
|
|
246
|
+
offset += chunk.length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
250
|
+
const bytes = new Uint8Array(digest);
|
|
251
|
+
let hex = '';
|
|
252
|
+
for (let i = 0; i < 8; i++) {
|
|
253
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
254
|
+
}
|
|
255
|
+
return hex;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function joinSandboxPath({
|
|
259
|
+
base,
|
|
260
|
+
path,
|
|
261
|
+
}: {
|
|
262
|
+
readonly base: string;
|
|
263
|
+
readonly path: string;
|
|
264
|
+
}): string {
|
|
265
|
+
return posix.join(base, path);
|
|
266
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { HarnessV1StreamPart } from '../../v1';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Remove the session working-directory prefix from path-bearing fields of a
|
|
5
|
+
* stream event, returning a new event for display to consumers.
|
|
6
|
+
*
|
|
7
|
+
* Harness adapters run the agent in a per-session working directory that is a
|
|
8
|
+
* subdirectory of the sandbox root, and the agent's tools use absolute paths so
|
|
9
|
+
* they resolve against the root regardless of where the runtime process
|
|
10
|
+
* operates. The absolute paths are correct but noisy in a UI, so this strips
|
|
11
|
+
* the prefix for the consumer-facing projection only.
|
|
12
|
+
*
|
|
13
|
+
* Blanket prefix replacement (rather than rewriting known path fields) is used
|
|
14
|
+
* deliberately: `tool-result` results are free-form text — command stdout, grep
|
|
15
|
+
* output — where paths can appear anywhere and field-aware rewriting is
|
|
16
|
+
* impossible. The prefix is long and contains the session id, so it is unique
|
|
17
|
+
* enough that replacing every occurrence is safe.
|
|
18
|
+
*/
|
|
19
|
+
export function stripWorkDir(
|
|
20
|
+
part: HarnessV1StreamPart,
|
|
21
|
+
sessionWorkDir: string,
|
|
22
|
+
): HarnessV1StreamPart {
|
|
23
|
+
if (sessionWorkDir.length === 0) return part;
|
|
24
|
+
|
|
25
|
+
switch (part.type) {
|
|
26
|
+
case 'tool-call':
|
|
27
|
+
return { ...part, input: stripString(part.input, sessionWorkDir) };
|
|
28
|
+
case 'tool-result':
|
|
29
|
+
return {
|
|
30
|
+
...part,
|
|
31
|
+
result: stripDeep(part.result, sessionWorkDir) as Extract<
|
|
32
|
+
HarnessV1StreamPart,
|
|
33
|
+
{ type: 'tool-result' }
|
|
34
|
+
>['result'],
|
|
35
|
+
};
|
|
36
|
+
case 'file-change':
|
|
37
|
+
return { ...part, path: stripString(part.path, sessionWorkDir) };
|
|
38
|
+
default:
|
|
39
|
+
return part;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Replace occurrences of the working directory in a string. A reference to the
|
|
45
|
+
* directory followed by a separator becomes workspace-relative
|
|
46
|
+
* (`/work/dir/src/a.ts` → `src/a.ts`); a bare reference to the directory itself
|
|
47
|
+
* becomes `.`.
|
|
48
|
+
*/
|
|
49
|
+
function stripString(value: string, workDir: string): string {
|
|
50
|
+
return value.split(`${workDir}/`).join('').split(workDir).join('.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recursively strip the working directory from every string nested in an
|
|
55
|
+
* arbitrary JSON-like value. Non-string leaves are returned unchanged.
|
|
56
|
+
*/
|
|
57
|
+
function stripDeep(value: unknown, workDir: string): unknown {
|
|
58
|
+
if (typeof value === 'string') return stripString(value, workDir);
|
|
59
|
+
if (Array.isArray(value)) return value.map(item => stripDeep(item, workDir));
|
|
60
|
+
if (value !== null && typeof value === 'object') {
|
|
61
|
+
const out: Record<string, unknown> = {};
|
|
62
|
+
for (const [key, val] of Object.entries(value)) {
|
|
63
|
+
out[key] = stripDeep(val, workDir);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { HarnessV1PromptControl } from '../../v1/harness-v1-prompt-control';
|
|
2
|
+
import type { HarnessV1StreamPart } from '../../v1/harness-v1-stream-part';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bridge an adapter's emit-based event surface into a pull-based
|
|
6
|
+
* `ReadableStream<HarnessV1StreamPart>`.
|
|
7
|
+
*
|
|
8
|
+
* Adapters implement `doPromptTurn` / `doContinueTurn` against an `emit` callback
|
|
9
|
+
* because that is the natural shape when wrapping an SDK that itself produces
|
|
10
|
+
* events. Consumers (notably `HarnessAgent`) prefer a stream because that is
|
|
11
|
+
* the idiomatic AI SDK shape. This helper converts the former into the latter
|
|
12
|
+
* and is agnostic to which turn entry point produced the control surface — the
|
|
13
|
+
* caller supplies an `invoke` thunk that wires `emit` into either method.
|
|
14
|
+
*
|
|
15
|
+
* Lifetime:
|
|
16
|
+
* 1. Calls `invoke(emit)` immediately (which runs `doPromptTurn`/`doContinueTurn`).
|
|
17
|
+
* 2. Every `emit(part)` becomes a stream chunk.
|
|
18
|
+
* 3. When `control.done` resolves, the stream closes — this includes a
|
|
19
|
+
* graceful `doSuspendTurn`, which resolves `done` cleanly after draining.
|
|
20
|
+
* 4. When `control.done` rejects, an `{ type: 'error', error }` part is
|
|
21
|
+
* enqueued and the stream is then closed normally. The rejection is
|
|
22
|
+
* surfaced to consumers as a discriminated-union event rather than as
|
|
23
|
+
* a stream error so iteration code does not need a separate try/catch
|
|
24
|
+
* around the consumer loop.
|
|
25
|
+
* 5. The supplied `abortSignal` (if any) aborts the underlying turn and
|
|
26
|
+
* closes the stream.
|
|
27
|
+
*
|
|
28
|
+
* The returned `control` is the same object the adapter produced and is
|
|
29
|
+
* intended for use by the consumer to submit tool results / approvals /
|
|
30
|
+
* user messages back into the in-flight turn.
|
|
31
|
+
*/
|
|
32
|
+
export async function toHarnessStream(options: {
|
|
33
|
+
invoke: (
|
|
34
|
+
emit: (event: HarnessV1StreamPart) => void,
|
|
35
|
+
) => PromiseLike<HarnessV1PromptControl>;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
stream: ReadableStream<HarnessV1StreamPart>;
|
|
38
|
+
control: HarnessV1PromptControl;
|
|
39
|
+
}> {
|
|
40
|
+
let controller!: ReadableStreamDefaultController<HarnessV1StreamPart>;
|
|
41
|
+
let closed = false;
|
|
42
|
+
|
|
43
|
+
const stream = new ReadableStream<HarnessV1StreamPart>({
|
|
44
|
+
start(c) {
|
|
45
|
+
controller = c;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const safeEnqueue = (part: HarnessV1StreamPart) => {
|
|
50
|
+
if (closed) return;
|
|
51
|
+
controller.enqueue(part);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const safeClose = () => {
|
|
55
|
+
if (closed) return;
|
|
56
|
+
closed = true;
|
|
57
|
+
controller.close();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const control = await options.invoke(safeEnqueue);
|
|
61
|
+
|
|
62
|
+
Promise.resolve(control.done)
|
|
63
|
+
.then(
|
|
64
|
+
() => safeClose(),
|
|
65
|
+
(err: unknown) => {
|
|
66
|
+
safeEnqueue({ type: 'error', error: err });
|
|
67
|
+
safeClose();
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
// Belt-and-suspenders: any throw inside the handlers themselves should
|
|
71
|
+
// not become an unhandled rejection.
|
|
72
|
+
.catch(() => {});
|
|
73
|
+
|
|
74
|
+
return { stream, control };
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|