@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,206 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import type { Telemetry } from 'ai';
|
|
3
|
+
import type { HarnessDiagnostic, HarnessDiagnosticConsumer } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A harness observability reporter that writes a unified, non-lossy
|
|
7
|
+
* `events.jsonl` containing **both** the telemetry span lifecycle (turn / step
|
|
8
|
+
* / tool) **and** the forwarded bridge diagnostics (console lines + structured
|
|
9
|
+
* events). It is a single object registered in `telemetry.integrations`: the
|
|
10
|
+
* framework drives its `Telemetry` methods for spans and calls
|
|
11
|
+
* `ingestDiagnostic` for diagnostics.
|
|
12
|
+
*
|
|
13
|
+
* No external collector or OTel setup required — this is the AI-SDK-idiomatic
|
|
14
|
+
* replacement for the original SDK's host-side artifact files.
|
|
15
|
+
*/
|
|
16
|
+
export interface FileReporterOptions {
|
|
17
|
+
/** Directory for `events.jsonl` (created if absent). */
|
|
18
|
+
dir: string;
|
|
19
|
+
/**
|
|
20
|
+
* Buffer a turn's records in memory and write them only if the turn produced
|
|
21
|
+
* an error (an `error`-level diagnostic, a failed tool, or an error finish).
|
|
22
|
+
* Default false (write everything).
|
|
23
|
+
*/
|
|
24
|
+
failOnly?: boolean;
|
|
25
|
+
/** File name within `dir`. Default `events.jsonl`. */
|
|
26
|
+
fileName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Record_ = { ts: number } & Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
export type FileReporter = Telemetry & HarnessDiagnosticConsumer;
|
|
32
|
+
|
|
33
|
+
export function createFileReporter(options: FileReporterOptions): FileReporter {
|
|
34
|
+
const fileName = options.fileName ?? 'events.jsonl';
|
|
35
|
+
const path = `${options.dir}/${fileName}`;
|
|
36
|
+
const failOnly = options.failOnly ?? false;
|
|
37
|
+
|
|
38
|
+
// Per-turn buffers, keyed by the telemetry callId. Diagnostics (which carry a
|
|
39
|
+
// sessionId, not a callId) attach to the most recently started, open turn.
|
|
40
|
+
const turns = new Map<string, { lines: Record_[]; errored: boolean }>();
|
|
41
|
+
let lastOpenCallId: string | undefined;
|
|
42
|
+
let dirReady = false;
|
|
43
|
+
|
|
44
|
+
const bucketFor = (callId: string) => {
|
|
45
|
+
let bucket = turns.get(callId);
|
|
46
|
+
if (!bucket) {
|
|
47
|
+
bucket = { lines: [], errored: false };
|
|
48
|
+
turns.set(callId, bucket);
|
|
49
|
+
}
|
|
50
|
+
return bucket;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const record = (callId: string | undefined, rec: Record_): void => {
|
|
54
|
+
const id = callId ?? lastOpenCallId;
|
|
55
|
+
if (id == null) {
|
|
56
|
+
// No active turn — write standalone (best-effort).
|
|
57
|
+
flushLines([rec]);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
bucketFor(id).lines.push(rec);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const flushLines = (lines: Record_[]): void => {
|
|
64
|
+
if (lines.length === 0) return;
|
|
65
|
+
try {
|
|
66
|
+
if (!dirReady) {
|
|
67
|
+
mkdirSync(options.dir, { recursive: true });
|
|
68
|
+
dirReady = true;
|
|
69
|
+
}
|
|
70
|
+
appendFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
|
71
|
+
} catch {
|
|
72
|
+
// Best-effort: never let observability break a turn.
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const finishTurn = (callId: string): void => {
|
|
77
|
+
const bucket = turns.get(callId);
|
|
78
|
+
if (!bucket) return;
|
|
79
|
+
turns.delete(callId);
|
|
80
|
+
if (lastOpenCallId === callId) lastOpenCallId = undefined;
|
|
81
|
+
if (failOnly && !bucket.errored) return;
|
|
82
|
+
flushLines(bucket.lines);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
onStart(event) {
|
|
87
|
+
const e = event as {
|
|
88
|
+
callId: string;
|
|
89
|
+
operationId?: string;
|
|
90
|
+
modelId?: string;
|
|
91
|
+
provider?: string;
|
|
92
|
+
messages?: unknown;
|
|
93
|
+
instructions?: unknown;
|
|
94
|
+
recordInputs?: boolean;
|
|
95
|
+
};
|
|
96
|
+
lastOpenCallId = e.callId;
|
|
97
|
+
record(e.callId, {
|
|
98
|
+
ts: Date.now(),
|
|
99
|
+
kind: 'turn-start',
|
|
100
|
+
callId: e.callId,
|
|
101
|
+
operationId: e.operationId,
|
|
102
|
+
provider: e.provider,
|
|
103
|
+
modelId: e.modelId,
|
|
104
|
+
// Input prompt, unless the consumer opted out via `recordInputs: false`.
|
|
105
|
+
...(e.recordInputs === false
|
|
106
|
+
? {}
|
|
107
|
+
: { input: { messages: e.messages, instructions: e.instructions } }),
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
onStepStart(event) {
|
|
111
|
+
const e = event as { callId: string; stepNumber?: number };
|
|
112
|
+
record(e.callId, {
|
|
113
|
+
ts: Date.now(),
|
|
114
|
+
kind: 'step-start',
|
|
115
|
+
callId: e.callId,
|
|
116
|
+
step: e.stepNumber,
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
onToolExecutionStart(event) {
|
|
120
|
+
const e = event as {
|
|
121
|
+
callId: string;
|
|
122
|
+
toolCall: { toolName: string; toolCallId: string; input: unknown };
|
|
123
|
+
};
|
|
124
|
+
record(e.callId, {
|
|
125
|
+
ts: Date.now(),
|
|
126
|
+
kind: 'tool-start',
|
|
127
|
+
callId: e.callId,
|
|
128
|
+
toolName: e.toolCall.toolName,
|
|
129
|
+
toolCallId: e.toolCall.toolCallId,
|
|
130
|
+
input: e.toolCall.input,
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
onToolExecutionEnd(event) {
|
|
134
|
+
const e = event as {
|
|
135
|
+
callId: string;
|
|
136
|
+
toolCall: { toolCallId: string };
|
|
137
|
+
toolOutput: { type: string; output?: unknown; error?: unknown };
|
|
138
|
+
};
|
|
139
|
+
const isError = e.toolOutput.type === 'error';
|
|
140
|
+
if (isError) bucketFor(e.callId).errored = true;
|
|
141
|
+
record(e.callId, {
|
|
142
|
+
ts: Date.now(),
|
|
143
|
+
kind: 'tool-end',
|
|
144
|
+
callId: e.callId,
|
|
145
|
+
toolCallId: e.toolCall.toolCallId,
|
|
146
|
+
isError,
|
|
147
|
+
output: isError ? e.toolOutput.error : e.toolOutput.output,
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
onStepFinish(event) {
|
|
151
|
+
const e = event as {
|
|
152
|
+
callId: string;
|
|
153
|
+
usage?: unknown;
|
|
154
|
+
content?: unknown[];
|
|
155
|
+
recordOutputs?: boolean;
|
|
156
|
+
};
|
|
157
|
+
record(e.callId, {
|
|
158
|
+
ts: Date.now(),
|
|
159
|
+
kind: 'step-finish',
|
|
160
|
+
callId: e.callId,
|
|
161
|
+
usage: e.usage,
|
|
162
|
+
// The model's output content, unless `recordOutputs: false`.
|
|
163
|
+
...(e.recordOutputs === false || e.content == null
|
|
164
|
+
? {}
|
|
165
|
+
: { output: e.content }),
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
onEnd(event) {
|
|
169
|
+
const e = event as {
|
|
170
|
+
callId: string;
|
|
171
|
+
finishReason?: unknown;
|
|
172
|
+
usage?: unknown;
|
|
173
|
+
totalUsage?: unknown;
|
|
174
|
+
};
|
|
175
|
+
record(e.callId, {
|
|
176
|
+
ts: Date.now(),
|
|
177
|
+
kind: 'turn-finish',
|
|
178
|
+
callId: e.callId,
|
|
179
|
+
finishReason: e.finishReason,
|
|
180
|
+
usage: e.totalUsage ?? e.usage,
|
|
181
|
+
});
|
|
182
|
+
finishTurn(e.callId);
|
|
183
|
+
},
|
|
184
|
+
onError(error) {
|
|
185
|
+
if (lastOpenCallId != null) bucketFor(lastOpenCallId).errored = true;
|
|
186
|
+
record(lastOpenCallId, {
|
|
187
|
+
ts: Date.now(),
|
|
188
|
+
kind: 'error',
|
|
189
|
+
error:
|
|
190
|
+
error instanceof Error
|
|
191
|
+
? { name: error.name, message: error.message }
|
|
192
|
+
: error,
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
ingestDiagnostic(diagnostic: HarnessDiagnostic) {
|
|
196
|
+
if (diagnostic.level === 'error' && lastOpenCallId != null) {
|
|
197
|
+
bucketFor(lastOpenCallId).errored = true;
|
|
198
|
+
}
|
|
199
|
+
record(lastOpenCallId, {
|
|
200
|
+
ts: diagnostic.timestamp,
|
|
201
|
+
kind: 'diagnostic',
|
|
202
|
+
diagnostic,
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createFileReporter,
|
|
3
|
+
type FileReporter,
|
|
4
|
+
type FileReporterOptions,
|
|
5
|
+
} from './file-reporter';
|
|
6
|
+
export {
|
|
7
|
+
createTraceTreeReporter,
|
|
8
|
+
type TraceTreeReporterOptions,
|
|
9
|
+
} from './trace-tree-reporter';
|
|
10
|
+
export type {
|
|
11
|
+
HarnessDebugConfig,
|
|
12
|
+
HarnessDebugLevel,
|
|
13
|
+
HarnessDiagnostic,
|
|
14
|
+
HarnessDiagnosticConsumer,
|
|
15
|
+
} from './types';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Telemetry } from 'ai';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A harness observability reporter that renders an ASCII trace tree of a
|
|
5
|
+
* turn's span lifecycle (turn → steps → tools) to a stream at turn end. It is a
|
|
6
|
+
* `Telemetry` integration — register it in `telemetry.integrations`. Useful for
|
|
7
|
+
* zero-setup local debugging when no OTel collector is wired up; a real OTel
|
|
8
|
+
* backend (via `@ai-sdk/otel`) is a strict superset.
|
|
9
|
+
*/
|
|
10
|
+
export interface TraceTreeReporterOptions {
|
|
11
|
+
/** Where to write the rendered tree. Default `process.stderr.write`. */
|
|
12
|
+
write?: (chunk: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Node = {
|
|
16
|
+
label: string;
|
|
17
|
+
startMs: number;
|
|
18
|
+
endMs?: number;
|
|
19
|
+
children: Node[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createTraceTreeReporter(
|
|
23
|
+
options: TraceTreeReporterOptions = {},
|
|
24
|
+
): Telemetry {
|
|
25
|
+
const write =
|
|
26
|
+
options.write ?? ((chunk: string) => void process.stderr.write(chunk));
|
|
27
|
+
|
|
28
|
+
type TurnState = {
|
|
29
|
+
root: Node;
|
|
30
|
+
step?: Node;
|
|
31
|
+
tools: Map<string, Node>;
|
|
32
|
+
};
|
|
33
|
+
const turns = new Map<string, TurnState>();
|
|
34
|
+
|
|
35
|
+
const render = (node: Node, depth: number): string => {
|
|
36
|
+
const indent = ' '.repeat(depth);
|
|
37
|
+
const dur =
|
|
38
|
+
node.endMs != null
|
|
39
|
+
? `${Math.max(0, Math.round(node.endMs - node.startMs))}ms`
|
|
40
|
+
: '(open)';
|
|
41
|
+
let out = `${indent}- ${node.label} ${dur}\n`;
|
|
42
|
+
for (const child of node.children) out += render(child, depth + 1);
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
onStart(event) {
|
|
48
|
+
const e = event as {
|
|
49
|
+
callId: string;
|
|
50
|
+
operationId?: string;
|
|
51
|
+
modelId?: string;
|
|
52
|
+
};
|
|
53
|
+
turns.set(e.callId, {
|
|
54
|
+
root: {
|
|
55
|
+
label: `${e.operationId ?? 'turn'}${e.modelId ? ` ${e.modelId}` : ''}`,
|
|
56
|
+
startMs: Date.now(),
|
|
57
|
+
children: [],
|
|
58
|
+
},
|
|
59
|
+
tools: new Map(),
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
onStepStart(event) {
|
|
63
|
+
const e = event as { callId: string; stepNumber?: number };
|
|
64
|
+
const turn = turns.get(e.callId);
|
|
65
|
+
if (!turn) return;
|
|
66
|
+
const step: Node = {
|
|
67
|
+
label: `step ${(e.stepNumber ?? turn.root.children.length) + 1}`,
|
|
68
|
+
startMs: Date.now(),
|
|
69
|
+
children: [],
|
|
70
|
+
};
|
|
71
|
+
turn.step = step;
|
|
72
|
+
turn.root.children.push(step);
|
|
73
|
+
},
|
|
74
|
+
onToolExecutionStart(event) {
|
|
75
|
+
const e = event as {
|
|
76
|
+
callId: string;
|
|
77
|
+
toolCall: { toolName: string; toolCallId: string };
|
|
78
|
+
};
|
|
79
|
+
const turn = turns.get(e.callId);
|
|
80
|
+
const parent = turn?.step ?? turn?.root;
|
|
81
|
+
if (!turn || !parent) return;
|
|
82
|
+
const node: Node = {
|
|
83
|
+
label: `tool ${e.toolCall.toolName}`,
|
|
84
|
+
startMs: Date.now(),
|
|
85
|
+
children: [],
|
|
86
|
+
};
|
|
87
|
+
turn.tools.set(e.toolCall.toolCallId, node);
|
|
88
|
+
parent.children.push(node);
|
|
89
|
+
},
|
|
90
|
+
onToolExecutionEnd(event) {
|
|
91
|
+
const e = event as {
|
|
92
|
+
callId: string;
|
|
93
|
+
toolCall: { toolCallId: string };
|
|
94
|
+
toolOutput: { type: string };
|
|
95
|
+
};
|
|
96
|
+
const node = turns.get(e.callId)?.tools.get(e.toolCall.toolCallId);
|
|
97
|
+
if (!node) return;
|
|
98
|
+
node.endMs = Date.now();
|
|
99
|
+
if (e.toolOutput.type === 'error') node.label += ' [error]';
|
|
100
|
+
},
|
|
101
|
+
onStepFinish(event) {
|
|
102
|
+
const e = event as { callId: string };
|
|
103
|
+
const turn = turns.get(e.callId);
|
|
104
|
+
if (turn?.step) {
|
|
105
|
+
turn.step.endMs = Date.now();
|
|
106
|
+
turn.step = undefined;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
onEnd(event) {
|
|
110
|
+
const e = event as { callId: string };
|
|
111
|
+
const turn = turns.get(e.callId);
|
|
112
|
+
if (!turn) return;
|
|
113
|
+
turn.root.endMs = Date.now();
|
|
114
|
+
turns.delete(e.callId);
|
|
115
|
+
try {
|
|
116
|
+
write(`\n${render(turn.root, 0)}`);
|
|
117
|
+
} catch {
|
|
118
|
+
// Never let rendering break the turn.
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Host/consumer-facing observability types.
|
|
3
|
+
*
|
|
4
|
+
* These are deliberately NOT part of the versioned adapter spec: no harness
|
|
5
|
+
* adapter implements or consumes them. Adapters only deal with bridge wire
|
|
6
|
+
* frames and the observability handle the framework hands to `doStart`. The
|
|
7
|
+
* framework normalizes those frames into the `HarnessDiagnostic` shape below
|
|
8
|
+
* for consumers (`HarnessAgentSettings.onLog`) and reporters.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Severity of a diagnostic. */
|
|
12
|
+
export type HarnessDebugLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Consumer-facing diagnostics configuration. Set on `HarnessAgentSettings` to
|
|
16
|
+
* enable bridge log forwarding and the `HARNESS_DEBUG` stderr default in code.
|
|
17
|
+
* `HARNESS_DEBUG` / `HARNESS_DEBUG_LEVEL` / `HARNESS_DEBUG_SUBSYSTEMS` env vars
|
|
18
|
+
* fill any unset field — a convenience default, never the only path.
|
|
19
|
+
*/
|
|
20
|
+
export type HarnessDebugConfig = {
|
|
21
|
+
/** Master switch. Nothing is captured or forwarded when false/unset. */
|
|
22
|
+
readonly enabled?: boolean;
|
|
23
|
+
/** Threshold; events at or above this severity are emitted. Default `debug`. */
|
|
24
|
+
readonly level?: HarnessDebugLevel;
|
|
25
|
+
/** Dotted-prefix subsystem filter for structured events. */
|
|
26
|
+
readonly subsystems?: ReadonlyArray<string>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A forwarded bridge diagnostic, normalized for host consumers.
|
|
31
|
+
*
|
|
32
|
+
* The bridge emits two raw frame kinds — captured console lines (`sandbox-log`)
|
|
33
|
+
* and structured events (`debug-event`). The framework normalizes both into
|
|
34
|
+
* this single shape before handing them to a consumer's `onLog` callback, the
|
|
35
|
+
* `HARNESS_DEBUG` stderr default, and observability reporters. Diagnostics are
|
|
36
|
+
* kept first-class and per-line — they are never folded into telemetry spans.
|
|
37
|
+
*/
|
|
38
|
+
export type HarnessDiagnostic = {
|
|
39
|
+
/**
|
|
40
|
+
* Severity. Structured events carry their own level; captured console lines
|
|
41
|
+
* map `stderr` → `'warn'` and `stdout` → `'info'`.
|
|
42
|
+
*/
|
|
43
|
+
readonly level: HarnessDebugLevel;
|
|
44
|
+
|
|
45
|
+
/** Human-readable line (console capture) or message (structured event). */
|
|
46
|
+
readonly message: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Dotted subsystem. For captured console output this is
|
|
50
|
+
* `sandbox.log.<source>`; for structured events it is the adapter-supplied
|
|
51
|
+
* subsystem (e.g. `bridge.turn`).
|
|
52
|
+
*/
|
|
53
|
+
readonly subsystem: string;
|
|
54
|
+
|
|
55
|
+
/** `'log'` = captured console line; `'event'` = structured `bridgeLog`. */
|
|
56
|
+
readonly kind: 'log' | 'event';
|
|
57
|
+
|
|
58
|
+
/** Originating sandbox source label (console capture). */
|
|
59
|
+
readonly source?: string;
|
|
60
|
+
|
|
61
|
+
/** Which standard stream the line came from (console capture). */
|
|
62
|
+
readonly stream?: 'stdout' | 'stderr';
|
|
63
|
+
|
|
64
|
+
/** Structured attributes (structured events only). */
|
|
65
|
+
readonly attrs?: Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
/** Error payload (structured events only). */
|
|
68
|
+
readonly error?: { name?: string; message: string; stack?: string };
|
|
69
|
+
|
|
70
|
+
/** The harness session this diagnostic originated from. */
|
|
71
|
+
readonly sessionId?: string;
|
|
72
|
+
|
|
73
|
+
/** Host receipt time (epoch ms). */
|
|
74
|
+
readonly timestamp: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A telemetry integration that also wants the per-line diagnostics stream. The
|
|
79
|
+
* framework calls `ingestDiagnostic` for every forwarded bridge diagnostic in
|
|
80
|
+
* addition to driving the standard `Telemetry` span lifecycle, so a single
|
|
81
|
+
* reporter object (e.g. `createFileReporter`) registered in
|
|
82
|
+
* `telemetry.integrations` receives both spans and logs.
|
|
83
|
+
*/
|
|
84
|
+
export interface HarnessDiagnosticConsumer {
|
|
85
|
+
ingestDiagnostic?(diagnostic: HarnessDiagnostic): void;
|
|
86
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { HarnessV1SandboxProvider } from '../v1';
|
|
2
|
+
import type { HarnessAgentAdapter } from './harness-agent-types';
|
|
3
|
+
import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
|
|
4
|
+
import { applyBootstrapRecipe } from './internal/bootstrap-recipe';
|
|
5
|
+
import {
|
|
6
|
+
createSandboxBootstrapPlan,
|
|
7
|
+
validateSandboxBootstrapSettings,
|
|
8
|
+
} from './internal/sandbox-bootstrap';
|
|
9
|
+
|
|
10
|
+
type SandboxBootstrapSettings = Omit<HarnessAgentSandboxConfig, 'onSession'>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Prepare a harness's sandbox template without running an agent. Idempotent: if
|
|
14
|
+
* the template already exists (snapshot present, or marker on a non-snapshot
|
|
15
|
+
* provider), this resolves quickly.
|
|
16
|
+
*
|
|
17
|
+
* Use from a CI/deploy script to amortize the first-session cost so production
|
|
18
|
+
* sessions always resume from snapshot. For adapters without a bootstrap
|
|
19
|
+
* recipe (no `getBootstrap`) this is a no-op.
|
|
20
|
+
*
|
|
21
|
+
* The temporary network sandbox session created during preparation is stopped
|
|
22
|
+
* before the function resolves; the snapshot/template state persists in the
|
|
23
|
+
* provider's native storage (for Vercel: as the `currentSnapshotId` of the
|
|
24
|
+
* named template sandbox).
|
|
25
|
+
*/
|
|
26
|
+
export async function prepareHarnessSandboxTemplate(options: {
|
|
27
|
+
readonly harness: HarnessAgentAdapter;
|
|
28
|
+
readonly sandboxProvider: HarnessV1SandboxProvider;
|
|
29
|
+
readonly sandboxConfig?: SandboxBootstrapSettings;
|
|
30
|
+
readonly abortSignal?: AbortSignal;
|
|
31
|
+
}): Promise<void> {
|
|
32
|
+
const sandboxConfig = options.sandboxConfig ?? {};
|
|
33
|
+
validateSandboxBootstrapSettings(sandboxConfig);
|
|
34
|
+
const recipe = await options.harness.getBootstrap?.({
|
|
35
|
+
abortSignal: options.abortSignal,
|
|
36
|
+
});
|
|
37
|
+
const bootstrapPlan = await createSandboxBootstrapPlan({
|
|
38
|
+
recipe,
|
|
39
|
+
settings: sandboxConfig,
|
|
40
|
+
});
|
|
41
|
+
if (bootstrapPlan.identity == null || bootstrapPlan.onFirstCreate == null) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sandboxSession = await options.sandboxProvider.createSession({
|
|
46
|
+
abortSignal: options.abortSignal,
|
|
47
|
+
identity: bootstrapPlan.identity,
|
|
48
|
+
onFirstCreate: bootstrapPlan.onFirstCreate,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (bootstrapPlan.recipe != null && bootstrapPlan.recipeIdentity != null) {
|
|
53
|
+
await applyBootstrapRecipe(
|
|
54
|
+
sandboxSession.restricted(),
|
|
55
|
+
bootstrapPlan.recipe,
|
|
56
|
+
bootstrapPlan.recipeIdentity,
|
|
57
|
+
{
|
|
58
|
+
abortSignal: options.abortSignal,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
await Promise.resolve(sandboxSession.stop()).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @deprecated Use `prepareHarnessSandboxTemplate` instead. */
|
|
68
|
+
export const prewarmHarness = prepareHarnessSandboxTemplate;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
2
|
+
import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
|
|
3
|
+
import type { HarnessAgentAdapter } from './harness-agent-types';
|
|
4
|
+
import {
|
|
5
|
+
applyBootstrapRecipe,
|
|
6
|
+
hashHarnessBootstrap,
|
|
7
|
+
} from './internal/bootstrap-recipe';
|
|
8
|
+
import {
|
|
9
|
+
normalizeSandboxWorkDir,
|
|
10
|
+
runSandboxBootstrap,
|
|
11
|
+
validateSandboxBootstrapSettings,
|
|
12
|
+
} from './internal/sandbox-bootstrap';
|
|
13
|
+
|
|
14
|
+
const PREPARED_SANDBOX_IDENTITY_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
export type PrepareSandboxForHarnessResult = {
|
|
17
|
+
readonly identity?: string;
|
|
18
|
+
readonly recipeIdentities: Record<string, string>;
|
|
19
|
+
readonly skippedHarnessIds: ReadonlyArray<string>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Apply one or more harness bootstrap recipes to an existing sandbox session.
|
|
24
|
+
*
|
|
25
|
+
* Use this when a sandbox provider or caller wants to prepare a reusable
|
|
26
|
+
* sandbox template, image, or snapshot before creating live harness sessions.
|
|
27
|
+
*
|
|
28
|
+
* The function writes each adapter's bridge/bootstrap files, runs its install
|
|
29
|
+
* commands, and then returns a deterministic identity derived from the applied
|
|
30
|
+
* recipes and optional sandbox bootstrap configuration. Providers can use that
|
|
31
|
+
* identity as the cache key for the prepared artifact.
|
|
32
|
+
*
|
|
33
|
+
* This function only mutates the supplied sandbox; the caller is responsible for
|
|
34
|
+
* committing, snapshotting, or otherwise persisting that modified filesystem.
|
|
35
|
+
* When a later `HarnessAgent` session uses a sandbox created from the persisted
|
|
36
|
+
* artifact, the adapter recomputes the same recipe identity and the existing
|
|
37
|
+
* bootstrap marker makes the bootstrap logic a no-op.
|
|
38
|
+
*/
|
|
39
|
+
export async function prepareSandboxForHarness(options: {
|
|
40
|
+
readonly session: SandboxSession;
|
|
41
|
+
readonly harnesses: ReadonlyArray<HarnessAgentAdapter>;
|
|
42
|
+
readonly sandboxConfig?: HarnessAgentSandboxConfig;
|
|
43
|
+
readonly abortSignal?: AbortSignal;
|
|
44
|
+
}): Promise<PrepareSandboxForHarnessResult> {
|
|
45
|
+
const sandboxConfig = options.sandboxConfig ?? {};
|
|
46
|
+
validateSandboxBootstrapSettings(sandboxConfig);
|
|
47
|
+
|
|
48
|
+
if (options.harnesses.length === 0) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
'prepareSandboxForHarness: at least one harness must be provided.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const harnesses = [...options.harnesses].sort((a, b) =>
|
|
55
|
+
a.harnessId.localeCompare(b.harnessId),
|
|
56
|
+
);
|
|
57
|
+
assertUniqueHarnessIds(harnesses);
|
|
58
|
+
|
|
59
|
+
const workDir =
|
|
60
|
+
sandboxConfig.workDir == null
|
|
61
|
+
? undefined
|
|
62
|
+
: normalizeSandboxWorkDir(sandboxConfig.workDir);
|
|
63
|
+
const recipeIdentities: Record<string, string> = {};
|
|
64
|
+
const skippedHarnessIds: string[] = [];
|
|
65
|
+
|
|
66
|
+
for (const harness of harnesses) {
|
|
67
|
+
const recipe = await harness.getBootstrap?.({
|
|
68
|
+
abortSignal: options.abortSignal,
|
|
69
|
+
});
|
|
70
|
+
if (recipe == null) {
|
|
71
|
+
skippedHarnessIds.push(harness.harnessId);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const recipeIdentity = await hashHarnessBootstrap(recipe);
|
|
76
|
+
recipeIdentities[harness.harnessId] = recipeIdentity;
|
|
77
|
+
await applyBootstrapRecipe(options.session, recipe, recipeIdentity, {
|
|
78
|
+
abortSignal: options.abortSignal,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (sandboxConfig.onBootstrap != null) {
|
|
83
|
+
await runSandboxBootstrap({
|
|
84
|
+
session: options.session,
|
|
85
|
+
workDir,
|
|
86
|
+
onBootstrap: sandboxConfig.onBootstrap,
|
|
87
|
+
abortSignal: options.abortSignal,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const identity = await resolvePreparedSandboxIdentity({
|
|
92
|
+
recipeIdentities,
|
|
93
|
+
bootstrapHash: sandboxConfig.bootstrapHash,
|
|
94
|
+
workDir,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
...(identity != null ? { identity } : {}),
|
|
99
|
+
recipeIdentities,
|
|
100
|
+
skippedHarnessIds,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertUniqueHarnessIds(
|
|
105
|
+
harnesses: ReadonlyArray<HarnessAgentAdapter>,
|
|
106
|
+
): void {
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
for (const harness of harnesses) {
|
|
109
|
+
if (seen.has(harness.harnessId)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`prepareSandboxForHarness: duplicate harness id "${harness.harnessId}".`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
seen.add(harness.harnessId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function resolvePreparedSandboxIdentity({
|
|
119
|
+
recipeIdentities,
|
|
120
|
+
bootstrapHash,
|
|
121
|
+
workDir,
|
|
122
|
+
}: {
|
|
123
|
+
readonly recipeIdentities: Record<string, string>;
|
|
124
|
+
readonly bootstrapHash?: string;
|
|
125
|
+
readonly workDir?: string;
|
|
126
|
+
}): Promise<string | undefined> {
|
|
127
|
+
const entries = Object.entries(recipeIdentities).sort(([a], [b]) =>
|
|
128
|
+
a.localeCompare(b),
|
|
129
|
+
);
|
|
130
|
+
if (entries.length === 0 && bootstrapHash == null) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const encoder = new TextEncoder();
|
|
135
|
+
const chunks: Uint8Array[] = [];
|
|
136
|
+
const pushString = (value: string) => {
|
|
137
|
+
chunks.push(encoder.encode(value));
|
|
138
|
+
chunks.push(encoder.encode('\0'));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
pushString(String(PREPARED_SANDBOX_IDENTITY_VERSION));
|
|
142
|
+
pushString(workDir ?? '');
|
|
143
|
+
pushString(bootstrapHash ?? '');
|
|
144
|
+
|
|
145
|
+
for (const [harnessId, identity] of entries) {
|
|
146
|
+
pushString(harnessId);
|
|
147
|
+
pushString(identity);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
151
|
+
const buffer = new Uint8Array(totalLength);
|
|
152
|
+
let offset = 0;
|
|
153
|
+
for (const chunk of chunks) {
|
|
154
|
+
buffer.set(chunk, offset);
|
|
155
|
+
offset += chunk.length;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
159
|
+
const bytes = new Uint8Array(digest);
|
|
160
|
+
let hex = '';
|
|
161
|
+
for (let i = 0; i < 8; i++) {
|
|
162
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
163
|
+
}
|
|
164
|
+
return hex;
|
|
165
|
+
}
|