@ai-sdk/harness 0.0.0 → 1.0.0-canary.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +13 -0
- package/README.md +142 -0
- package/agent/index.ts +17 -0
- package/bridge/index.ts +10 -0
- package/dist/agent/index.d.ts +1480 -0
- package/dist/agent/index.js +2554 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/bridge/index.d.ts +111 -0
- package/dist/bridge/index.js +414 -0
- package/dist/bridge/index.js.map +1 -0
- package/dist/index.d.ts +1510 -0
- package/dist/index.js +15834 -0
- package/dist/index.js.map +1 -0
- package/dist/observability/index.d.ts +97 -0
- package/dist/observability/index.js +225 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/utils/index.d.ts +196 -0
- package/dist/utils/index.js +327 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +104 -1
- package/src/agent/harness-agent-session.ts +352 -0
- package/src/agent/harness-agent-settings.ts +131 -0
- package/src/agent/harness-agent-tool-approval-continuation.ts +94 -0
- package/src/agent/harness-agent.ts +750 -0
- package/src/agent/harness-diagnostics.ts +88 -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 +720 -0
- package/src/agent/internal/permission-mode.ts +50 -0
- package/src/agent/internal/resolve-observability.ts +128 -0
- package/src/agent/internal/resume-state-validation.ts +51 -0
- package/src/agent/internal/run-prompt.ts +811 -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/translate-stream-part.ts +221 -0
- package/src/agent/internal/turn-telemetry.ts +359 -0
- package/src/agent/prewarm.ts +46 -0
- package/src/bridge/index.ts +700 -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/observability/file-reporter.ts +209 -0
- package/src/observability/index.ts +13 -0
- package/src/observability/trace-tree-reporter.ts +122 -0
- package/src/utils/classify-disk-log.ts +43 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/sandbox-channel.ts +453 -0
- package/src/v1/harness-v1-bootstrap.ts +46 -0
- package/src/v1/harness-v1-bridge-protocol.ts +310 -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-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-resume-state.ts +46 -0
- package/src/v1/harness-v1-sandbox-provider.ts +76 -0
- package/src/v1/harness-v1-session.ts +268 -0
- package/src/v1/harness-v1-skill.ts +22 -0
- package/src/v1/harness-v1-stream-part.ts +363 -0
- package/src/v1/harness-v1-tool-spec.ts +31 -0
- package/src/v1/harness-v1.ts +83 -0
- package/src/v1/index.ts +93 -0
- package/utils/index.ts +1 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { AISDKError } from '@ai-sdk/provider';
|
|
2
|
+
import { HarnessError } from './harness-error';
|
|
3
|
+
|
|
4
|
+
const name = 'AI_HarnessCapabilityUnsupportedError';
|
|
5
|
+
const marker = `vercel.ai.error.${name}`;
|
|
6
|
+
const symbol = Symbol.for(marker);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown when a caller asks the harness to do something the adapter (or the
|
|
10
|
+
* supplied sandbox) does not support, e.g. requesting manual compaction from
|
|
11
|
+
* an adapter that only auto-compacts, or invoking `getPortUrl` on a sandbox
|
|
12
|
+
* that does not expose one.
|
|
13
|
+
*
|
|
14
|
+
* The caller supplies the full human-readable message. Optional `harnessId`
|
|
15
|
+
* is recorded as structured context for tooling.
|
|
16
|
+
*/
|
|
17
|
+
export class HarnessCapabilityUnsupportedError extends HarnessError {
|
|
18
|
+
private readonly [symbol] = true;
|
|
19
|
+
|
|
20
|
+
readonly harnessId?: string;
|
|
21
|
+
|
|
22
|
+
constructor({
|
|
23
|
+
message,
|
|
24
|
+
harnessId,
|
|
25
|
+
cause,
|
|
26
|
+
}: {
|
|
27
|
+
message: string;
|
|
28
|
+
harnessId?: string;
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
}) {
|
|
31
|
+
super({ message, cause });
|
|
32
|
+
Object.defineProperty(this, 'name', { value: name });
|
|
33
|
+
this.harnessId = harnessId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static isInstance(
|
|
37
|
+
error: unknown,
|
|
38
|
+
): error is HarnessCapabilityUnsupportedError {
|
|
39
|
+
return AISDKError.hasMarker(error, marker);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { AISDKError } from '@ai-sdk/provider';
|
|
2
|
+
|
|
3
|
+
const name = 'AI_HarnessError';
|
|
4
|
+
const marker = `vercel.ai.error.${name}`;
|
|
5
|
+
const symbol = Symbol.for(marker);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base error type for failures originating in or signalled by a harness
|
|
9
|
+
* adapter. Specific failure modes (e.g. unsupported capability) extend this
|
|
10
|
+
* class.
|
|
11
|
+
*/
|
|
12
|
+
export class HarnessError extends AISDKError {
|
|
13
|
+
private readonly [symbol] = true;
|
|
14
|
+
|
|
15
|
+
constructor({ message, cause }: { message: string; cause?: unknown }) {
|
|
16
|
+
super({ name, message, cause });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static isInstance(error: unknown): error is HarnessError {
|
|
20
|
+
return AISDKError.hasMarker(error, marker);
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import type { Telemetry } from 'ai';
|
|
3
|
+
import type {
|
|
4
|
+
HarnessDiagnostic,
|
|
5
|
+
HarnessDiagnosticConsumer,
|
|
6
|
+
} from '../agent/harness-diagnostics';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A harness observability reporter that writes a unified, non-lossy
|
|
10
|
+
* `events.jsonl` containing **both** the telemetry span lifecycle (turn / step
|
|
11
|
+
* / tool) **and** the forwarded bridge diagnostics (console lines + structured
|
|
12
|
+
* events). It is a single object registered in `telemetry.integrations`: the
|
|
13
|
+
* framework drives its `Telemetry` methods for spans and calls
|
|
14
|
+
* `ingestDiagnostic` for diagnostics.
|
|
15
|
+
*
|
|
16
|
+
* No external collector or OTel setup required — this is the AI-SDK-idiomatic
|
|
17
|
+
* replacement for the original SDK's host-side artifact files.
|
|
18
|
+
*/
|
|
19
|
+
export interface FileReporterOptions {
|
|
20
|
+
/** Directory for `events.jsonl` (created if absent). */
|
|
21
|
+
dir: string;
|
|
22
|
+
/**
|
|
23
|
+
* Buffer a turn's records in memory and write them only if the turn produced
|
|
24
|
+
* an error (an `error`-level diagnostic, a failed tool, or an error finish).
|
|
25
|
+
* Default false (write everything).
|
|
26
|
+
*/
|
|
27
|
+
failOnly?: boolean;
|
|
28
|
+
/** File name within `dir`. Default `events.jsonl`. */
|
|
29
|
+
fileName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Record_ = { ts: number } & Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
export type FileReporter = Telemetry & HarnessDiagnosticConsumer;
|
|
35
|
+
|
|
36
|
+
export function createFileReporter(options: FileReporterOptions): FileReporter {
|
|
37
|
+
const fileName = options.fileName ?? 'events.jsonl';
|
|
38
|
+
const path = `${options.dir}/${fileName}`;
|
|
39
|
+
const failOnly = options.failOnly ?? false;
|
|
40
|
+
|
|
41
|
+
// Per-turn buffers, keyed by the telemetry callId. Diagnostics (which carry a
|
|
42
|
+
// sessionId, not a callId) attach to the most recently started, open turn.
|
|
43
|
+
const turns = new Map<string, { lines: Record_[]; errored: boolean }>();
|
|
44
|
+
let lastOpenCallId: string | undefined;
|
|
45
|
+
let dirReady = false;
|
|
46
|
+
|
|
47
|
+
const bucketFor = (callId: string) => {
|
|
48
|
+
let bucket = turns.get(callId);
|
|
49
|
+
if (!bucket) {
|
|
50
|
+
bucket = { lines: [], errored: false };
|
|
51
|
+
turns.set(callId, bucket);
|
|
52
|
+
}
|
|
53
|
+
return bucket;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const record = (callId: string | undefined, rec: Record_): void => {
|
|
57
|
+
const id = callId ?? lastOpenCallId;
|
|
58
|
+
if (id == null) {
|
|
59
|
+
// No active turn — write standalone (best-effort).
|
|
60
|
+
flushLines([rec]);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
bucketFor(id).lines.push(rec);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const flushLines = (lines: Record_[]): void => {
|
|
67
|
+
if (lines.length === 0) return;
|
|
68
|
+
try {
|
|
69
|
+
if (!dirReady) {
|
|
70
|
+
mkdirSync(options.dir, { recursive: true });
|
|
71
|
+
dirReady = true;
|
|
72
|
+
}
|
|
73
|
+
appendFileSync(path, lines.map(l => JSON.stringify(l)).join('\n') + '\n');
|
|
74
|
+
} catch {
|
|
75
|
+
// Best-effort: never let observability break a turn.
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const finishTurn = (callId: string): void => {
|
|
80
|
+
const bucket = turns.get(callId);
|
|
81
|
+
if (!bucket) return;
|
|
82
|
+
turns.delete(callId);
|
|
83
|
+
if (lastOpenCallId === callId) lastOpenCallId = undefined;
|
|
84
|
+
if (failOnly && !bucket.errored) return;
|
|
85
|
+
flushLines(bucket.lines);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
onStart(event) {
|
|
90
|
+
const e = event as {
|
|
91
|
+
callId: string;
|
|
92
|
+
operationId?: string;
|
|
93
|
+
modelId?: string;
|
|
94
|
+
provider?: string;
|
|
95
|
+
messages?: unknown;
|
|
96
|
+
instructions?: unknown;
|
|
97
|
+
recordInputs?: boolean;
|
|
98
|
+
};
|
|
99
|
+
lastOpenCallId = e.callId;
|
|
100
|
+
record(e.callId, {
|
|
101
|
+
ts: Date.now(),
|
|
102
|
+
kind: 'turn-start',
|
|
103
|
+
callId: e.callId,
|
|
104
|
+
operationId: e.operationId,
|
|
105
|
+
provider: e.provider,
|
|
106
|
+
modelId: e.modelId,
|
|
107
|
+
// Input prompt, unless the consumer opted out via `recordInputs: false`.
|
|
108
|
+
...(e.recordInputs === false
|
|
109
|
+
? {}
|
|
110
|
+
: { input: { messages: e.messages, instructions: e.instructions } }),
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
onStepStart(event) {
|
|
114
|
+
const e = event as { callId: string; stepNumber?: number };
|
|
115
|
+
record(e.callId, {
|
|
116
|
+
ts: Date.now(),
|
|
117
|
+
kind: 'step-start',
|
|
118
|
+
callId: e.callId,
|
|
119
|
+
step: e.stepNumber,
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
onToolExecutionStart(event) {
|
|
123
|
+
const e = event as {
|
|
124
|
+
callId: string;
|
|
125
|
+
toolCall: { toolName: string; toolCallId: string; input: unknown };
|
|
126
|
+
};
|
|
127
|
+
record(e.callId, {
|
|
128
|
+
ts: Date.now(),
|
|
129
|
+
kind: 'tool-start',
|
|
130
|
+
callId: e.callId,
|
|
131
|
+
toolName: e.toolCall.toolName,
|
|
132
|
+
toolCallId: e.toolCall.toolCallId,
|
|
133
|
+
input: e.toolCall.input,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
onToolExecutionEnd(event) {
|
|
137
|
+
const e = event as {
|
|
138
|
+
callId: string;
|
|
139
|
+
toolCall: { toolCallId: string };
|
|
140
|
+
toolOutput: { type: string; output?: unknown; error?: unknown };
|
|
141
|
+
};
|
|
142
|
+
const isError = e.toolOutput.type === 'error';
|
|
143
|
+
if (isError) bucketFor(e.callId).errored = true;
|
|
144
|
+
record(e.callId, {
|
|
145
|
+
ts: Date.now(),
|
|
146
|
+
kind: 'tool-end',
|
|
147
|
+
callId: e.callId,
|
|
148
|
+
toolCallId: e.toolCall.toolCallId,
|
|
149
|
+
isError,
|
|
150
|
+
output: isError ? e.toolOutput.error : e.toolOutput.output,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
onStepFinish(event) {
|
|
154
|
+
const e = event as {
|
|
155
|
+
callId: string;
|
|
156
|
+
usage?: unknown;
|
|
157
|
+
content?: unknown[];
|
|
158
|
+
recordOutputs?: boolean;
|
|
159
|
+
};
|
|
160
|
+
record(e.callId, {
|
|
161
|
+
ts: Date.now(),
|
|
162
|
+
kind: 'step-finish',
|
|
163
|
+
callId: e.callId,
|
|
164
|
+
usage: e.usage,
|
|
165
|
+
// The model's output content, unless `recordOutputs: false`.
|
|
166
|
+
...(e.recordOutputs === false || e.content == null
|
|
167
|
+
? {}
|
|
168
|
+
: { output: e.content }),
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
onEnd(event) {
|
|
172
|
+
const e = event as {
|
|
173
|
+
callId: string;
|
|
174
|
+
finishReason?: unknown;
|
|
175
|
+
usage?: unknown;
|
|
176
|
+
totalUsage?: unknown;
|
|
177
|
+
};
|
|
178
|
+
record(e.callId, {
|
|
179
|
+
ts: Date.now(),
|
|
180
|
+
kind: 'turn-finish',
|
|
181
|
+
callId: e.callId,
|
|
182
|
+
finishReason: e.finishReason,
|
|
183
|
+
usage: e.totalUsage ?? e.usage,
|
|
184
|
+
});
|
|
185
|
+
finishTurn(e.callId);
|
|
186
|
+
},
|
|
187
|
+
onError(error) {
|
|
188
|
+
if (lastOpenCallId != null) bucketFor(lastOpenCallId).errored = true;
|
|
189
|
+
record(lastOpenCallId, {
|
|
190
|
+
ts: Date.now(),
|
|
191
|
+
kind: 'error',
|
|
192
|
+
error:
|
|
193
|
+
error instanceof Error
|
|
194
|
+
? { name: error.name, message: error.message }
|
|
195
|
+
: error,
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
ingestDiagnostic(diagnostic: HarnessDiagnostic) {
|
|
199
|
+
if (diagnostic.level === 'error' && lastOpenCallId != null) {
|
|
200
|
+
bucketFor(lastOpenCallId).errored = true;
|
|
201
|
+
}
|
|
202
|
+
record(lastOpenCallId, {
|
|
203
|
+
ts: diagnostic.timestamp,
|
|
204
|
+
kind: 'diagnostic',
|
|
205
|
+
diagnostic,
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
HarnessDiagnostic,
|
|
12
|
+
HarnessDiagnosticConsumer,
|
|
13
|
+
} from '../agent/harness-diagnostics';
|
|
@@ -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,43 @@
|
|
|
1
|
+
import { safeParseJSON } from '@ai-sdk/provider-utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recovery rung selected from an on-disk bridge event log when attach is not
|
|
5
|
+
* possible (the bridge process is gone): `'replay'` when the log holds a
|
|
6
|
+
* complete turn the host can resume from its cursor, `'rerun'` otherwise.
|
|
7
|
+
*/
|
|
8
|
+
export type DiskLogRecoveryMode = 'replay' | 'rerun';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Decide whether a respawned bridge can `replay` a turn from its persisted
|
|
12
|
+
* `event-log.ndjson`, or must `rerun` it from scratch.
|
|
13
|
+
*
|
|
14
|
+
* A turn is replayable only when its log ends in a terminal `finish` event —
|
|
15
|
+
* a log that is missing, empty, or ends mid-turn means the bridge died before
|
|
16
|
+
* completing the turn, so there is no coherent tail to deliver and the runtime
|
|
17
|
+
* must re-run it (continuing its own thread from the sandbox snapshot).
|
|
18
|
+
*
|
|
19
|
+
* @param eventLog Raw contents of `event-log.ndjson` (newline-delimited JSON),
|
|
20
|
+
* or `null`/`undefined`/empty when the file is absent.
|
|
21
|
+
*/
|
|
22
|
+
export async function classifyDiskLog(
|
|
23
|
+
eventLog: string | null | undefined,
|
|
24
|
+
): Promise<DiskLogRecoveryMode> {
|
|
25
|
+
if (!eventLog) return 'rerun';
|
|
26
|
+
const lines = eventLog
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map(line => line.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
const lastLine = lines.at(-1);
|
|
31
|
+
if (lastLine == null) return 'rerun';
|
|
32
|
+
|
|
33
|
+
const parsed = await safeParseJSON({ text: lastLine });
|
|
34
|
+
if (
|
|
35
|
+
parsed.success &&
|
|
36
|
+
parsed.value != null &&
|
|
37
|
+
typeof parsed.value === 'object' &&
|
|
38
|
+
(parsed.value as { type?: unknown }).type === 'finish'
|
|
39
|
+
) {
|
|
40
|
+
return 'replay';
|
|
41
|
+
}
|
|
42
|
+
return 'rerun';
|
|
43
|
+
}
|