engineering-behavior-observatory 0.2.1
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/LICENSE +202 -0
- package/README.md +151 -0
- package/contracts/codex-app-server-0.150.1/manifest.json +25 -0
- package/contracts/codex-app-server-0.150.1/schema/InitializeParams.json +84 -0
- package/contracts/codex-app-server-0.150.1/schema/JSONRPCMessage.json +137 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/ThreadReadParams.json +17 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/ThreadTokenUsageUpdatedNotification.json +82 -0
- package/contracts/codex-app-server-0.150.1/schema/v2/TurnInterruptParams.json +17 -0
- package/contracts/codex-app-server-0.150.1/types/AskForApproval.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/ClientNotification.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/SandboxMode.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/ThreadReadParams.ts +9 -0
- package/contracts/codex-app-server-0.150.1/types/TokenUsageBreakdown.ts +5 -0
- package/contracts/codex-app-server-0.150.1/types/TurnInterruptParams.ts +5 -0
- package/contracts/codex-app-server-0.153.4/manifest.json +27 -0
- package/contracts/codex-app-server-0.153.4/schema/JSONRPCMessage.json +137 -0
- package/contracts/codex-app-server-0.153.4/schema/v1/InitializeParams.json +84 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadReadParams.json +17 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadStartParams.json +515 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/ThreadTokenUsageUpdatedNotification.json +82 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/TurnInterruptParams.json +17 -0
- package/contracts/codex-app-server-0.153.4/schema/v2/TurnStartParams.json +921 -0
- package/contracts/codex-app-server-0.153.4/types/AskForApproval.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/ClientNotification.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/SandboxMode.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/ThreadReadParams.ts +12 -0
- package/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.ts +5 -0
- package/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.ts +5 -0
- package/contracts/openhands-agent-server-v1.44.1.json +65 -0
- package/contracts/openhands-agent-server-v1.46.0.json +46 -0
- package/dist/contracts/codex-app-server-0.153.4/types/AskForApproval.d.ts +9 -0
- package/dist/contracts/codex-app-server-0.153.4/types/AskForApproval.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ClientNotification.d.ts +3 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ClientNotification.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/SandboxMode.d.ts +1 -0
- package/dist/contracts/codex-app-server-0.153.4/types/SandboxMode.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ThreadReadParams.d.ts +10 -0
- package/dist/contracts/codex-app-server-0.153.4/types/ThreadReadParams.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.d.ts +8 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TokenUsageBreakdown.js +2 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.d.ts +4 -0
- package/dist/contracts/codex-app-server-0.153.4/types/TurnInterruptParams.js +2 -0
- package/dist/src/agent-sdk-normalizer.d.ts +22 -0
- package/dist/src/agent-sdk-normalizer.js +995 -0
- package/dist/src/agent-sdk-run.d.ts +31 -0
- package/dist/src/agent-sdk-run.js +242 -0
- package/dist/src/agent-sdk-runner.d.ts +96 -0
- package/dist/src/agent-sdk-runner.js +407 -0
- package/dist/src/agent-sdk.d.ts +294 -0
- package/dist/src/agent-sdk.js +644 -0
- package/dist/src/aggregation.d.ts +183 -0
- package/dist/src/aggregation.js +513 -0
- package/dist/src/artifacts.d.ts +36 -0
- package/dist/src/artifacts.js +1372 -0
- package/dist/src/atlas-grafana.d.ts +377 -0
- package/dist/src/atlas-grafana.js +47 -0
- package/dist/src/atlas-html.d.ts +2 -0
- package/dist/src/atlas-html.js +34 -0
- package/dist/src/atlas.d.ts +130 -0
- package/dist/src/atlas.js +235 -0
- package/dist/src/behavior-assertions.d.ts +95 -0
- package/dist/src/behavior-assertions.js +154 -0
- package/dist/src/cli.d.ts +5 -0
- package/dist/src/cli.js +957 -0
- package/dist/src/codex-judge.d.ts +44 -0
- package/dist/src/codex-judge.js +214 -0
- package/dist/src/codex-run.d.ts +114 -0
- package/dist/src/codex-run.js +519 -0
- package/dist/src/codex.d.ts +187 -0
- package/dist/src/codex.js +1028 -0
- package/dist/src/contracts.d.ts +155 -0
- package/dist/src/contracts.js +866 -0
- package/dist/src/corpus.d.ts +67 -0
- package/dist/src/corpus.js +540 -0
- package/dist/src/cursor-sdk-runner.d.ts +81 -0
- package/dist/src/cursor-sdk-runner.js +362 -0
- package/dist/src/cursor-sdk.d.ts +81 -0
- package/dist/src/cursor-sdk.js +1078 -0
- package/dist/src/deepseek-adapter.d.ts +152 -0
- package/dist/src/deepseek-adapter.js +777 -0
- package/dist/src/exports.d.ts +69 -0
- package/dist/src/exports.js +966 -0
- package/dist/src/human-calibration.d.ts +167 -0
- package/dist/src/human-calibration.js +618 -0
- package/dist/src/index.d.ts +71 -0
- package/dist/src/index.js +38 -0
- package/dist/src/lifecycle.d.ts +196 -0
- package/dist/src/lifecycle.js +1889 -0
- package/dist/src/normalization-integrity.d.ts +128 -0
- package/dist/src/normalization-integrity.js +429 -0
- package/dist/src/openhands-run.d.ts +36 -0
- package/dist/src/openhands-run.js +415 -0
- package/dist/src/openhands.d.ts +130 -0
- package/dist/src/openhands.js +859 -0
- package/dist/src/pi.d.ts +203 -0
- package/dist/src/pi.js +1345 -0
- package/dist/src/process-protocol.d.ts +245 -0
- package/dist/src/process-protocol.js +1261 -0
- package/dist/src/retained-evidence.d.ts +12 -0
- package/dist/src/retained-evidence.js +201 -0
- package/dist/src/run-bundles.d.ts +193 -0
- package/dist/src/run-bundles.js +993 -0
- package/dist/src/scheduler.d.ts +109 -0
- package/dist/src/scheduler.js +1080 -0
- package/dist/src/semantic-judge.d.ts +194 -0
- package/dist/src/semantic-judge.js +875 -0
- package/dist/src/structural-observations.d.ts +124 -0
- package/dist/src/structural-observations.js +671 -0
- package/dist/src/task-packets.d.ts +115 -0
- package/dist/src/task-packets.js +683 -0
- package/dist/src/uniform-events.d.ts +120 -0
- package/dist/src/uniform-events.js +158 -0
- package/dist/src/verifiers.d.ts +116 -0
- package/dist/src/verifiers.js +819 -0
- package/dist/src/workspaces.d.ts +56 -0
- package/dist/src/workspaces.js +1314 -0
- package/docs/README.md +60 -0
- package/docs/development/README.md +71 -0
- package/docs/development/documentation-sync.md +58 -0
- package/docs/development/extension-contracts.md +307 -0
- package/docs/evaluation/README.md +48 -0
- package/docs/evaluation/aggregation.md +112 -0
- package/docs/evaluation/behavior-assertions.md +45 -0
- package/docs/evaluation/human-calibration.md +170 -0
- package/docs/evaluation/normalization-integrity.md +80 -0
- package/docs/evaluation/semantic-judge.md +188 -0
- package/docs/evaluation/structural-observations.md +84 -0
- package/docs/evaluation/uniform-events.md +131 -0
- package/docs/guides/atlas.md +144 -0
- package/docs/guides/evidence-and-sharing.md +100 -0
- package/docs/guides/operator-guide.md +424 -0
- package/docs/guides/quickstart.md +152 -0
- package/docs/guides/telemetry.md +66 -0
- package/docs/harnesses/README.md +31 -0
- package/docs/harnesses/claude-agent-sdk.md +61 -0
- package/docs/harnesses/codex-harness.md +174 -0
- package/docs/harnesses/cursor-sdk.md +149 -0
- package/docs/harnesses/deepseek-harness.md +134 -0
- package/docs/harnesses/openhands-agent-server.md +153 -0
- package/docs/harnesses/pi-sdk.md +66 -0
- package/docs/reference/README.md +19 -0
- package/docs/reference/agent-sdk-operational-runner.md +118 -0
- package/docs/reference/cli.md +114 -0
- package/docs/reference/contracts.md +222 -0
- package/docs/reference/run-bundle-contract.md +354 -0
- package/docs/reference/run-lifecycle.md +54 -0
- package/examples/cursor-sdk/README.md +11 -0
- package/examples/cursor-sdk/capture-profile.json +1 -0
- package/examples/cursor-sdk/harness.json +1 -0
- package/examples/cursor-sdk/model.json +1 -0
- package/examples/cursor-sdk/native-limits.json +1 -0
- package/examples/cursor-sdk/native-tool-policy.json +1 -0
- package/ontology/behavior-categories.v1.json +46 -0
- package/package.json +66 -0
- package/release/0.1.0/KNOWN_LIMITATIONS.md +35 -0
- package/release/0.1.0/README.md +46 -0
- package/release/0.1.0/reproducibility.json +61 -0
- package/release/0.2.0/KNOWN_LIMITATIONS.md +40 -0
- package/release/0.2.0/README.md +42 -0
- package/release/0.2.0/reproducibility.json +73 -0
- package/release/0.2.1/KNOWN_LIMITATIONS.md +18 -0
- package/release/0.2.1/README.md +63 -0
- package/release/0.2.1/reproducibility.json +73 -0
- package/release/README.md +13 -0
- package/schemas/aggregation.v1.json +293 -0
- package/schemas/behavior-assertions.v1.json +220 -0
- package/schemas/deepseek-runtime-composition.v1.schema.json +122 -0
- package/schemas/experiment.v1.schema.json +188 -0
- package/schemas/human-calibration.v1.json +336 -0
- package/schemas/normalization-integrity.v1.json +302 -0
- package/schemas/run-bundles/v1.json +1040 -0
- package/schemas/run-queue.v1.schema.json +206 -0
- package/schemas/semantic-judge.v1.json +380 -0
- package/schemas/structural-observations.v1.json +131 -0
- package/schemas/task-packet-freeze.v1.schema.json +106 -0
- package/schemas/task-packet.v1.schema.json +234 -0
- package/schemas/uniform-events/v1.json +289 -0
- package/scripts/atlas-grafana.sh +18 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
export const OPENHANDS_AGENT_SERVER_VERSION = "1.46.0";
|
|
2
|
+
export const OPENHANDS_TYPESCRIPT_CLIENT_VERSION = "1.39.0";
|
|
3
|
+
export const OPENHANDS_DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
export const OPENHANDS_MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
5
|
+
export const OPENHANDS_DEFAULT_MAX_CAPTURE_BYTES = 256 * 1024 * 1024;
|
|
6
|
+
export const OPENHANDS_MAX_CAPTURE_BYTES = 1024 * 1024 * 1024;
|
|
7
|
+
export const OPENHANDS_AGENT_SERVER_CAPABILITIES = {
|
|
8
|
+
schemaVersion: "ebo.adapter-capability-profile/v1",
|
|
9
|
+
adapterId: "openhands-agent-server-v1.46.0",
|
|
10
|
+
harness: "openhands-agent-server",
|
|
11
|
+
nativeTypes: [
|
|
12
|
+
"MessageEvent",
|
|
13
|
+
"SystemPromptEvent",
|
|
14
|
+
"ActionEvent",
|
|
15
|
+
"ObservationEvent",
|
|
16
|
+
"AgentErrorEvent",
|
|
17
|
+
"UserRejectObservation",
|
|
18
|
+
"CondensationRequest",
|
|
19
|
+
"Condensation",
|
|
20
|
+
"CondensationSummaryEvent",
|
|
21
|
+
"ConversationErrorEvent",
|
|
22
|
+
"ServerErrorEvent",
|
|
23
|
+
"HookExecutionEvent",
|
|
24
|
+
"ConversationStateUpdateEvent",
|
|
25
|
+
"InterruptEvent",
|
|
26
|
+
"PauseEvent",
|
|
27
|
+
"conversation-final",
|
|
28
|
+
],
|
|
29
|
+
families: {
|
|
30
|
+
message: { status: "available" },
|
|
31
|
+
"model-request": { status: "unsupported", detail: "The pinned public event boundary does not expose model requests." },
|
|
32
|
+
tool: { status: "available" },
|
|
33
|
+
context: { status: "available" },
|
|
34
|
+
permission: { status: "unsupported", detail: "Hook blocking is retained natively but is not projected as a permission event." },
|
|
35
|
+
delegation: { status: "unsupported", detail: "Nested delegate actions remain source-specific tool evidence." },
|
|
36
|
+
artifact: { status: "unsupported", detail: "Workspace outcome is packaged outside the Agent Server event normalizer." },
|
|
37
|
+
validation: { status: "unsupported", detail: "Verifier evidence is packaged outside the Agent Server event normalizer." },
|
|
38
|
+
runtime: { status: "available" },
|
|
39
|
+
outcome: { status: "partial", detail: "Final conversation state is available when the run reaches the final REST read." },
|
|
40
|
+
},
|
|
41
|
+
evidence: {
|
|
42
|
+
nativeOrder: { status: "partial", detail: "REST page and WebSocket receipt order remain separate source domains." },
|
|
43
|
+
nativeTime: { status: "partial", detail: "Event timestamps are available; control records may omit native time." },
|
|
44
|
+
parentage: { status: "partial", detail: "Event parent_id and selected action associations are projected when their targets are captured." },
|
|
45
|
+
content: { status: "partial", detail: "Supported content stays in native records and is referenced rather than copied." },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
/** Retained evidence keeps the identity of the runtime that produced it. */
|
|
49
|
+
export function openHandsCapabilityProfile(version) {
|
|
50
|
+
if (version !== "1.44.1" && version !== OPENHANDS_AGENT_SERVER_VERSION) {
|
|
51
|
+
throw new Error(`Unsupported retained OpenHands runtime ${version}.`);
|
|
52
|
+
}
|
|
53
|
+
return { ...OPENHANDS_AGENT_SERVER_CAPABILITIES, adapterId: `openhands-agent-server-v${version}` };
|
|
54
|
+
}
|
|
55
|
+
const TERMINAL_STATUSES = new Set(["finished", "error", "stuck"]);
|
|
56
|
+
const MAX_CAPTURE_EVENTS = 10_000;
|
|
57
|
+
const MAX_TRANSPORT_STATUS_RECORDS = 256;
|
|
58
|
+
class CaptureClosedSocket extends Error {
|
|
59
|
+
}
|
|
60
|
+
export function createOpenHandsHarnessAdapter() {
|
|
61
|
+
return {
|
|
62
|
+
capture: {
|
|
63
|
+
id: OPENHANDS_AGENT_SERVER_CAPABILITIES.adapterId,
|
|
64
|
+
harness: OPENHANDS_AGENT_SERVER_CAPABILITIES.harness,
|
|
65
|
+
capture: captureOpenHandsAgentServer,
|
|
66
|
+
},
|
|
67
|
+
normalization: {
|
|
68
|
+
id: OPENHANDS_AGENT_SERVER_CAPABILITIES.adapterId,
|
|
69
|
+
harness: OPENHANDS_AGENT_SERVER_CAPABILITIES.harness,
|
|
70
|
+
capabilityProfile: OPENHANDS_AGENT_SERVER_CAPABILITIES,
|
|
71
|
+
normalize: normalizeOpenHandsCapture,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export async function captureOpenHandsAgentServer(request) {
|
|
76
|
+
if (request.runId.trim() === "" || request.attemptId.trim() === "") {
|
|
77
|
+
throw new Error("OpenHands capture requires non-empty run and attempt identities.");
|
|
78
|
+
}
|
|
79
|
+
const maxResponseBytes = request.maxResponseBytes ?? OPENHANDS_DEFAULT_MAX_RESPONSE_BYTES;
|
|
80
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > OPENHANDS_MAX_RESPONSE_BYTES) {
|
|
81
|
+
throw new Error(`OpenHands max response bytes must be between 1 and ${OPENHANDS_MAX_RESPONSE_BYTES}.`);
|
|
82
|
+
}
|
|
83
|
+
const maxCaptureBytes = request.maxCaptureBytes ?? OPENHANDS_DEFAULT_MAX_CAPTURE_BYTES;
|
|
84
|
+
if (!Number.isSafeInteger(maxCaptureBytes) || maxCaptureBytes < 1 || maxCaptureBytes > OPENHANDS_MAX_CAPTURE_BYTES) {
|
|
85
|
+
throw new Error(`OpenHands max capture bytes must be between 1 and ${OPENHANDS_MAX_CAPTURE_BYTES}.`);
|
|
86
|
+
}
|
|
87
|
+
assertIntegerOption(request.timeoutMs, 1, "timeout milliseconds");
|
|
88
|
+
assertIntegerOption(request.pollIntervalMs, 0, "poll interval milliseconds");
|
|
89
|
+
assertIntegerOption(request.maxReconnects, 0, "max reconnects");
|
|
90
|
+
assertIntegerOption(request.reconnectDelayMs, 0, "reconnect delay milliseconds");
|
|
91
|
+
assertNotAborted(request.signal);
|
|
92
|
+
const baseUrl = normalizedBaseUrl(request.baseUrl);
|
|
93
|
+
const fetch = request.fetch ?? globalThis.fetch;
|
|
94
|
+
const records = [];
|
|
95
|
+
let sequence = 0;
|
|
96
|
+
let conversationId;
|
|
97
|
+
const publish = (captured) => {
|
|
98
|
+
request.onRecord?.(structuredClone(captured));
|
|
99
|
+
};
|
|
100
|
+
const append = (channel, payload, channelSequence) => {
|
|
101
|
+
sequence += 1;
|
|
102
|
+
const record = {
|
|
103
|
+
schemaVersion: "ebo.openhands-native-record/v1",
|
|
104
|
+
...(conversationId === undefined ? {} : { session_id: conversationId }),
|
|
105
|
+
channel,
|
|
106
|
+
sequence,
|
|
107
|
+
...(channelSequence === undefined ? {} : { channelSequence }),
|
|
108
|
+
payload: structuredClone(payload),
|
|
109
|
+
};
|
|
110
|
+
const captured = {
|
|
111
|
+
reference: { artifactId: "session", recordLocator: `line:${sequence}` },
|
|
112
|
+
record,
|
|
113
|
+
};
|
|
114
|
+
records.push(captured);
|
|
115
|
+
publish(captured);
|
|
116
|
+
};
|
|
117
|
+
let serverInfo = {};
|
|
118
|
+
try {
|
|
119
|
+
serverInfo = await requestJson(fetch, baseUrl, "/server_info", undefined, request.sessionApiKey, maxResponseBytes, request.signal, request.timeoutMs);
|
|
120
|
+
append("server-info", serverInfo);
|
|
121
|
+
if (serverInfo.version !== OPENHANDS_AGENT_SERVER_VERSION) {
|
|
122
|
+
throw new Error(`OpenHands Agent Server ${OPENHANDS_AGENT_SERVER_VERSION} is required; received ${String(serverInfo.version)}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
const captureError = errorMessage(error);
|
|
127
|
+
append("capture-error", { error: captureError.slice(0, 512) });
|
|
128
|
+
return {
|
|
129
|
+
runId: request.runId,
|
|
130
|
+
attemptId: request.attemptId,
|
|
131
|
+
qualification: "qualified-with-gaps",
|
|
132
|
+
records,
|
|
133
|
+
serverInfo,
|
|
134
|
+
captureError,
|
|
135
|
+
reconciliation: {
|
|
136
|
+
status: "partial",
|
|
137
|
+
streamedEventIds: [],
|
|
138
|
+
finalEventIds: [],
|
|
139
|
+
streamedOnlyEventIds: [],
|
|
140
|
+
finalOnlyEventIds: [],
|
|
141
|
+
transportGaps: ["server-info-unavailable"],
|
|
142
|
+
},
|
|
143
|
+
eventLogCompleteness: {
|
|
144
|
+
status: "unknown",
|
|
145
|
+
reason: "REST/WebSocket reconciliation cannot prove complete in-process EventLog delivery.",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
let created;
|
|
150
|
+
try {
|
|
151
|
+
created = await requestJson(fetch, baseUrl, "/api/conversations", {
|
|
152
|
+
method: "POST",
|
|
153
|
+
body: JSON.stringify(request.startConversation),
|
|
154
|
+
}, request.sessionApiKey, maxResponseBytes, request.signal, request.timeoutMs);
|
|
155
|
+
if (typeof created.id !== "string" || created.id.length > 255 || !/\S/u.test(created.id)) {
|
|
156
|
+
append("conversation-create-response", created);
|
|
157
|
+
throw new Error("OpenHands conversation creation did not return a projectable conversation identity.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const captureError = errorMessage(error);
|
|
162
|
+
append("capture-error", { error: captureError.slice(0, 512) });
|
|
163
|
+
return {
|
|
164
|
+
runId: request.runId,
|
|
165
|
+
attemptId: request.attemptId,
|
|
166
|
+
qualification: "qualified-with-gaps",
|
|
167
|
+
records,
|
|
168
|
+
serverInfo,
|
|
169
|
+
captureError,
|
|
170
|
+
reconciliation: {
|
|
171
|
+
status: "partial",
|
|
172
|
+
streamedEventIds: [],
|
|
173
|
+
finalEventIds: [],
|
|
174
|
+
streamedOnlyEventIds: [],
|
|
175
|
+
finalOnlyEventIds: [],
|
|
176
|
+
transportGaps: ["conversation-not-created"],
|
|
177
|
+
},
|
|
178
|
+
eventLogCompleteness: {
|
|
179
|
+
status: "unknown",
|
|
180
|
+
reason: "REST/WebSocket reconciliation cannot prove complete in-process EventLog delivery.",
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
conversationId = created.id;
|
|
185
|
+
append("conversation-created", created);
|
|
186
|
+
const streamed = [];
|
|
187
|
+
const finalEvents = [];
|
|
188
|
+
let transportStatusRecords = 0;
|
|
189
|
+
let transportStatusLimitRecorded = false;
|
|
190
|
+
const retainTransportStatus = (status) => {
|
|
191
|
+
if (transportStatusRecords < MAX_TRANSPORT_STATUS_RECORDS) {
|
|
192
|
+
transportStatusRecords += 1;
|
|
193
|
+
append("websocket-status", status);
|
|
194
|
+
}
|
|
195
|
+
else if (!transportStatusLimitRecorded) {
|
|
196
|
+
transportStatusLimitRecorded = true;
|
|
197
|
+
append("websocket-status", { state: "status-limit-reached", limit: MAX_TRANSPORT_STATUS_RECORDS });
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
let finalConversation;
|
|
201
|
+
let finalReadError;
|
|
202
|
+
let captureError;
|
|
203
|
+
let socket;
|
|
204
|
+
try {
|
|
205
|
+
socket = await openEventSocket(request, baseUrl, conversationId, (event) => {
|
|
206
|
+
if (streamed.length >= MAX_CAPTURE_EVENTS) {
|
|
207
|
+
if (!records.some(({ record }) => record.channel === "websocket-status" && record.payload.state === "event-limit-reached")) {
|
|
208
|
+
retainTransportStatus({ state: "event-limit-reached", limit: MAX_CAPTURE_EVENTS });
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
streamed.push(event);
|
|
213
|
+
append("websocket-event", event, streamed.length);
|
|
214
|
+
}, retainTransportStatus);
|
|
215
|
+
await requestJson(fetch, baseUrl, `/api/conversations/${encodeURIComponent(conversationId)}/events`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
body: JSON.stringify(request.message),
|
|
218
|
+
}, request.sessionApiKey, maxResponseBytes, request.signal, request.timeoutMs);
|
|
219
|
+
finalConversation = await pollConversation(fetch, baseUrl, conversationId, request);
|
|
220
|
+
append("conversation-final", finalConversation);
|
|
221
|
+
try {
|
|
222
|
+
await readFinalEvents(fetch, baseUrl, conversationId, request.sessionApiKey, maxResponseBytes, maxCaptureBytes, request.signal, request.timeoutMs, (event, index) => {
|
|
223
|
+
finalEvents.push(event);
|
|
224
|
+
append("rest-event", event, index);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
finalReadError = errorMessage(error);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
captureError = errorMessage(error);
|
|
233
|
+
if (socket === undefined)
|
|
234
|
+
retainTransportStatus({ state: "socket-open-failed", reason: captureError.slice(0, 512) });
|
|
235
|
+
append("capture-error", { error: captureError.slice(0, 512) });
|
|
236
|
+
const recoverySignal = request.signal?.aborted ? AbortSignal.timeout(100) : request.signal;
|
|
237
|
+
try {
|
|
238
|
+
await readFinalEvents(fetch, baseUrl, conversationId, request.sessionApiKey, maxResponseBytes, maxCaptureBytes, recoverySignal, request.timeoutMs, (event, index) => {
|
|
239
|
+
finalEvents.push(event);
|
|
240
|
+
append("rest-event", event, index);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (readError) {
|
|
244
|
+
finalReadError = errorMessage(readError);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
socket?.close();
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const cleanupSignal = request.signal?.aborted ? AbortSignal.timeout(100) : request.signal;
|
|
252
|
+
await requestJson(fetch, baseUrl, `/api/conversations/${encodeURIComponent(conversationId)}`, {
|
|
253
|
+
method: "DELETE",
|
|
254
|
+
}, request.sessionApiKey, maxResponseBytes, cleanupSignal, request.timeoutMs);
|
|
255
|
+
append("cleanup", { success: true });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
append("cleanup", { success: false, error: errorMessage(error).slice(0, 512) });
|
|
259
|
+
}
|
|
260
|
+
const streamedEventIds = eventIds(streamed);
|
|
261
|
+
const finalEventIds = eventIds(finalEvents);
|
|
262
|
+
const streamedSet = new Set(streamedEventIds);
|
|
263
|
+
const finalSet = new Set(finalEventIds);
|
|
264
|
+
const streamedOnlyEventIds = streamedEventIds.filter((id) => !finalSet.has(id));
|
|
265
|
+
const finalOnlyEventIds = finalEventIds.filter((id) => !streamedSet.has(id));
|
|
266
|
+
const transportGaps = [...new Set([
|
|
267
|
+
...records.flatMap(({ record }) => record.channel === "websocket-status"
|
|
268
|
+
&& ["message-rejected", "socket-open-failed", "reconnect-exhausted", "reconnect-failed", "event-limit-reached", "event-byte-limit-reached", "status-limit-reached"].includes(String(record.payload.state))
|
|
269
|
+
? [String(record.payload.state)] : []),
|
|
270
|
+
...(streamed.some((event) => nativeEventId(event) === undefined) ? ["unidentified-websocket-event"] : []),
|
|
271
|
+
...(finalEvents.some((event) => nativeEventId(event) === undefined) ? ["unidentified-rest-event"] : []),
|
|
272
|
+
])];
|
|
273
|
+
return {
|
|
274
|
+
runId: request.runId,
|
|
275
|
+
attemptId: request.attemptId,
|
|
276
|
+
qualification: "qualified-with-gaps",
|
|
277
|
+
records,
|
|
278
|
+
conversationId,
|
|
279
|
+
serverInfo,
|
|
280
|
+
...(finalConversation === undefined ? {} : { finalConversation }),
|
|
281
|
+
...(captureError === undefined ? {} : { captureError }),
|
|
282
|
+
reconciliation: {
|
|
283
|
+
status: captureError !== undefined || finalReadError !== undefined || transportGaps.length > 0 ? "partial"
|
|
284
|
+
: streamedOnlyEventIds.length === 0 && finalOnlyEventIds.length === 0 ? "matched" : "mismatch",
|
|
285
|
+
streamedEventIds,
|
|
286
|
+
finalEventIds,
|
|
287
|
+
streamedOnlyEventIds,
|
|
288
|
+
finalOnlyEventIds,
|
|
289
|
+
transportGaps,
|
|
290
|
+
...(finalReadError === undefined ? {} : { finalReadError }),
|
|
291
|
+
},
|
|
292
|
+
eventLogCompleteness: {
|
|
293
|
+
status: "unknown",
|
|
294
|
+
reason: "REST/WebSocket reconciliation cannot prove complete in-process EventLog delivery.",
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
export async function normalizeOpenHandsCapture(capture) {
|
|
299
|
+
const conversationId = capture.records.find(({ record }) => record.session_id !== undefined)?.record.session_id;
|
|
300
|
+
if (conversationId === undefined) {
|
|
301
|
+
return {
|
|
302
|
+
events: [],
|
|
303
|
+
unmapped: capture.records.map(({ reference }) => ({
|
|
304
|
+
reference,
|
|
305
|
+
reason: "capture ended before the Agent Server assigned a conversation identity",
|
|
306
|
+
})),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const canonical = new Map();
|
|
310
|
+
for (const captured of capture.records) {
|
|
311
|
+
if (captured.record.channel !== "rest-event")
|
|
312
|
+
continue;
|
|
313
|
+
const id = nativeEventId(captured.record.payload);
|
|
314
|
+
if (id !== undefined)
|
|
315
|
+
canonical.set(id, captured);
|
|
316
|
+
}
|
|
317
|
+
for (const captured of capture.records) {
|
|
318
|
+
if (captured.record.channel !== "websocket-event")
|
|
319
|
+
continue;
|
|
320
|
+
const id = nativeEventId(captured.record.payload);
|
|
321
|
+
if (id !== undefined && !canonical.has(id))
|
|
322
|
+
canonical.set(id, captured);
|
|
323
|
+
}
|
|
324
|
+
const events = [];
|
|
325
|
+
const mappedReferences = new Set();
|
|
326
|
+
const eventIdByNativeId = new Map();
|
|
327
|
+
for (const [id, captured] of canonical) {
|
|
328
|
+
const native = captured.record.payload;
|
|
329
|
+
const kind = typeof native.kind === "string" ? native.kind : "";
|
|
330
|
+
const eventId = uniformEventId(id);
|
|
331
|
+
if (mappedEvent(kind) !== undefined && nativeEventSource(native.source) !== undefined && eventId !== undefined) {
|
|
332
|
+
eventIdByNativeId.set(id, eventId);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
for (const [nativeId, captured] of canonical) {
|
|
336
|
+
if (!eventIdByNativeId.has(nativeId))
|
|
337
|
+
continue;
|
|
338
|
+
const mapped = mapOpenHandsEvent(capture, conversationId, captured, nativeId, eventIdByNativeId);
|
|
339
|
+
if (mapped === undefined)
|
|
340
|
+
continue;
|
|
341
|
+
events.push(mapped);
|
|
342
|
+
mappedReferences.add(referenceKey(captured.reference));
|
|
343
|
+
}
|
|
344
|
+
for (const captured of capture.records.filter(({ record }) => record.channel === "conversation-final")) {
|
|
345
|
+
events.push(mapOpenHandsOutcome(capture, conversationId, captured));
|
|
346
|
+
mappedReferences.add(referenceKey(captured.reference));
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
events,
|
|
350
|
+
unmapped: capture.records
|
|
351
|
+
.filter(({ reference }) => !mappedReferences.has(referenceKey(reference)))
|
|
352
|
+
.map(({ reference, record }) => ({
|
|
353
|
+
reference,
|
|
354
|
+
reason: record.channel === "websocket-event" && canonical.has(nativeEventId(record.payload) ?? "")
|
|
355
|
+
? "reconciled duplicate; final REST event is canonical"
|
|
356
|
+
: "native record has no uniform mapping",
|
|
357
|
+
})),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function mapOpenHandsOutcome(capture, conversationId, captured) {
|
|
361
|
+
const native = captured.record.payload;
|
|
362
|
+
const attributes = {};
|
|
363
|
+
copyScalar(attributes, "executionStatus", native.execution_status);
|
|
364
|
+
if (isRecord(native.workspace)) {
|
|
365
|
+
copyScalar(attributes, "workspaceKind", native.workspace.kind ?? native.workspace.type);
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
schemaVersion: "ebo.uniform-event/v1",
|
|
369
|
+
id: `openhands:control:conversation-final:${captured.record.sequence}`,
|
|
370
|
+
runId: capture.runId,
|
|
371
|
+
attemptId: capture.attemptId,
|
|
372
|
+
source: {
|
|
373
|
+
harness: "openhands-agent-server",
|
|
374
|
+
nativeType: "conversation-final",
|
|
375
|
+
nativeReference: captured.reference,
|
|
376
|
+
},
|
|
377
|
+
nativeOrder: { status: "unknown", reason: "Conversation status records have no native sequence." },
|
|
378
|
+
nativeTime: isOffsetDateTime(native.updated_at)
|
|
379
|
+
? { status: "known", value: native.updated_at }
|
|
380
|
+
: { status: "unknown", reason: "Final conversation record omitted an RFC 3339 update time with an explicit offset." },
|
|
381
|
+
actor: { kind: "harness", id: "openhands-agent-server" },
|
|
382
|
+
family: "outcome",
|
|
383
|
+
phase: "after",
|
|
384
|
+
scope: { kind: "session", id: conversationId },
|
|
385
|
+
relations: {
|
|
386
|
+
parent: { status: "unknown", reason: "Agent Server does not relate final conversation state to one event." },
|
|
387
|
+
known: [],
|
|
388
|
+
},
|
|
389
|
+
attributes,
|
|
390
|
+
content: {
|
|
391
|
+
status: "known",
|
|
392
|
+
value: [{
|
|
393
|
+
nativeReference: {
|
|
394
|
+
artifactId: captured.reference.artifactId,
|
|
395
|
+
recordLocator: `${captured.reference.recordLocator}#/payload`,
|
|
396
|
+
},
|
|
397
|
+
mediaType: "application/json",
|
|
398
|
+
}],
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function mapOpenHandsEvent(capture, conversationId, captured, nativeId, eventIdByNativeId) {
|
|
403
|
+
const native = captured.record.payload;
|
|
404
|
+
const kind = typeof native.kind === "string" ? native.kind : undefined;
|
|
405
|
+
if (kind === undefined)
|
|
406
|
+
return undefined;
|
|
407
|
+
const mapping = mappedEvent(kind);
|
|
408
|
+
if (mapping === undefined)
|
|
409
|
+
return undefined;
|
|
410
|
+
const source = nativeEventSource(native.source);
|
|
411
|
+
if (source === undefined)
|
|
412
|
+
return undefined;
|
|
413
|
+
const parentId = typeof native.parent_id === "string" ? eventIdByNativeId.get(native.parent_id) : undefined;
|
|
414
|
+
const knownRelations = [];
|
|
415
|
+
if (kind === "ObservationEvent" && typeof native.action_id === "string") {
|
|
416
|
+
const actionId = eventIdByNativeId.get(native.action_id);
|
|
417
|
+
if (actionId !== undefined)
|
|
418
|
+
knownRelations.push({ kind: "caused-by", eventId: actionId });
|
|
419
|
+
}
|
|
420
|
+
if (kind === "HookExecutionEvent") {
|
|
421
|
+
const relatedId = typeof native.action_id === "string" ? native.action_id
|
|
422
|
+
: typeof native.message_id === "string" ? native.message_id : undefined;
|
|
423
|
+
const eventId = relatedId === undefined ? undefined : eventIdByNativeId.get(relatedId);
|
|
424
|
+
if (eventId !== undefined)
|
|
425
|
+
knownRelations.push({ kind: "correlates-with", eventId });
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
schemaVersion: "ebo.uniform-event/v1",
|
|
429
|
+
id: eventIdByNativeId.get(nativeId),
|
|
430
|
+
runId: capture.runId,
|
|
431
|
+
attemptId: capture.attemptId,
|
|
432
|
+
source: {
|
|
433
|
+
harness: "openhands-agent-server",
|
|
434
|
+
nativeType: kind,
|
|
435
|
+
nativeReference: captured.reference,
|
|
436
|
+
},
|
|
437
|
+
nativeOrder: captured.record.channelSequence === undefined
|
|
438
|
+
? { status: "unknown", reason: "Agent Server record has no native sequence." }
|
|
439
|
+
: {
|
|
440
|
+
status: "known",
|
|
441
|
+
value: captured.record.channelSequence,
|
|
442
|
+
domain: captured.record.channel === "rest-event" ? "agent-server-rest" : "agent-server-websocket",
|
|
443
|
+
},
|
|
444
|
+
nativeTime: isOffsetDateTime(native.timestamp)
|
|
445
|
+
? { status: "known", value: native.timestamp }
|
|
446
|
+
: { status: "unknown", reason: "Agent Server event omitted an RFC 3339 timestamp with an explicit offset." },
|
|
447
|
+
actor: { kind: actorForSource(source), id: source },
|
|
448
|
+
family: mapping.family,
|
|
449
|
+
phase: mapping.phase,
|
|
450
|
+
scope: { kind: "session", id: conversationId },
|
|
451
|
+
relations: {
|
|
452
|
+
parent: parentId === undefined
|
|
453
|
+
? { status: "unknown", reason: "No captured mapped parent event is available." }
|
|
454
|
+
: { status: "known", value: parentId },
|
|
455
|
+
known: knownRelations,
|
|
456
|
+
},
|
|
457
|
+
attributes: eventAttributes(kind, source, native),
|
|
458
|
+
content: eventContent(kind, captured),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function mappedEvent(kind) {
|
|
462
|
+
if (kind === "MessageEvent" || kind === "SystemPromptEvent")
|
|
463
|
+
return { family: "message", phase: "instant" };
|
|
464
|
+
if (kind === "ActionEvent")
|
|
465
|
+
return { family: "tool", phase: "before" };
|
|
466
|
+
if (["ObservationEvent", "AgentErrorEvent", "UserRejectObservation"].includes(kind))
|
|
467
|
+
return { family: "tool", phase: "after" };
|
|
468
|
+
if (kind === "CondensationRequest")
|
|
469
|
+
return { family: "context", phase: "before" };
|
|
470
|
+
if (kind === "Condensation")
|
|
471
|
+
return { family: "context", phase: "during" };
|
|
472
|
+
if (kind === "CondensationSummaryEvent")
|
|
473
|
+
return { family: "context", phase: "after" };
|
|
474
|
+
if (["ConversationErrorEvent", "ServerErrorEvent", "HookExecutionEvent", "ConversationStateUpdateEvent", "InterruptEvent", "PauseEvent"].includes(kind)) {
|
|
475
|
+
return { family: "runtime", phase: "instant" };
|
|
476
|
+
}
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
function eventAttributes(kind, source, native) {
|
|
480
|
+
const attributes = {
|
|
481
|
+
nativeSource: source,
|
|
482
|
+
};
|
|
483
|
+
copyScalar(attributes, "toolName", native.tool_name);
|
|
484
|
+
copyScalar(attributes, "toolCallId", native.tool_call_id);
|
|
485
|
+
copyScalar(attributes, "actionId", native.action_id);
|
|
486
|
+
copyScalar(attributes, "llmResponseId", native.llm_response_id);
|
|
487
|
+
if (kind === "AgentErrorEvent")
|
|
488
|
+
attributes.errorScope = "agent-tool";
|
|
489
|
+
if (kind === "ConversationErrorEvent")
|
|
490
|
+
attributes.errorScope = "conversation";
|
|
491
|
+
if (kind === "ServerErrorEvent")
|
|
492
|
+
attributes.errorScope = "server";
|
|
493
|
+
if (["ConversationErrorEvent", "ServerErrorEvent"].includes(kind))
|
|
494
|
+
copyScalar(attributes, "errorCode", native.code);
|
|
495
|
+
if (kind === "HookExecutionEvent") {
|
|
496
|
+
copyScalar(attributes, "hookEventType", native.hook_event_type);
|
|
497
|
+
copyScalar(attributes, "success", native.success);
|
|
498
|
+
copyScalar(attributes, "exitCode", native.exit_code);
|
|
499
|
+
copyScalar(attributes, "blocked", native.blocked);
|
|
500
|
+
}
|
|
501
|
+
if (kind === "ConversationStateUpdateEvent") {
|
|
502
|
+
copyScalar(attributes, "stateKey", native.key);
|
|
503
|
+
copyScalar(attributes, "stateValue", native.value);
|
|
504
|
+
}
|
|
505
|
+
if (kind === "Condensation" && Array.isArray(native.forgotten_event_ids)
|
|
506
|
+
&& native.forgotten_event_ids.length <= 16
|
|
507
|
+
&& native.forgotten_event_ids.every((value) => typeof value === "string" && value.length <= 512)) {
|
|
508
|
+
attributes.forgottenEventIds = native.forgotten_event_ids;
|
|
509
|
+
}
|
|
510
|
+
return attributes;
|
|
511
|
+
}
|
|
512
|
+
function copyScalar(target, name, value) {
|
|
513
|
+
if (typeof value === "string" && value.length <= 512 || typeof value === "number" && Number.isFinite(value)
|
|
514
|
+
|| typeof value === "boolean" || value === null)
|
|
515
|
+
target[name] = value;
|
|
516
|
+
}
|
|
517
|
+
function eventContent(kind, captured) {
|
|
518
|
+
const native = captured.record.payload;
|
|
519
|
+
const llmMessage = isRecord(native.llm_message) ? native.llm_message : undefined;
|
|
520
|
+
const pointer = kind === "MessageEvent" && llmMessage !== undefined && Object.hasOwn(llmMessage, "content")
|
|
521
|
+
? "/payload/llm_message/content"
|
|
522
|
+
: kind === "SystemPromptEvent" && Object.hasOwn(native, "system_prompt") ? "/payload/system_prompt"
|
|
523
|
+
: kind === "ActionEvent" && Object.hasOwn(native, "action") ? "/payload/action"
|
|
524
|
+
: ["ObservationEvent", "UserRejectObservation"].includes(kind) && Object.hasOwn(native, "observation") ? "/payload/observation"
|
|
525
|
+
: kind === "AgentErrorEvent" && Object.hasOwn(native, "error") ? "/payload/error"
|
|
526
|
+
: ["ConversationErrorEvent", "ServerErrorEvent"].includes(kind) && Object.hasOwn(native, "detail") ? "/payload/detail"
|
|
527
|
+
: kind === "Condensation" || kind === "HookExecutionEvent" ? "/payload"
|
|
528
|
+
: kind === "CondensationSummaryEvent" && Object.hasOwn(native, "summary") ? "/payload/summary"
|
|
529
|
+
: undefined;
|
|
530
|
+
return pointer === undefined
|
|
531
|
+
? { status: "unknown", reason: "Pinned Agent Server event has no mapped content field." }
|
|
532
|
+
: {
|
|
533
|
+
status: "known",
|
|
534
|
+
value: [{
|
|
535
|
+
nativeReference: {
|
|
536
|
+
artifactId: captured.reference.artifactId,
|
|
537
|
+
recordLocator: `${captured.reference.recordLocator}#${pointer}`,
|
|
538
|
+
},
|
|
539
|
+
mediaType: "application/json",
|
|
540
|
+
}],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function nativeEventSource(value) {
|
|
544
|
+
return typeof value === "string" && ["user", "agent", "environment", "hook"].includes(value)
|
|
545
|
+
? value : undefined;
|
|
546
|
+
}
|
|
547
|
+
function actorForSource(source) {
|
|
548
|
+
if (source === "user")
|
|
549
|
+
return "user";
|
|
550
|
+
if (source === "agent")
|
|
551
|
+
return "agent";
|
|
552
|
+
return "harness";
|
|
553
|
+
}
|
|
554
|
+
async function openEventSocket(request, baseUrl, conversationId, onEvent, onStatus) {
|
|
555
|
+
let current;
|
|
556
|
+
let lastTimestamp;
|
|
557
|
+
let reconnects = 0;
|
|
558
|
+
let stopped = false;
|
|
559
|
+
let cancelPendingOpen;
|
|
560
|
+
let acceptedEventBytes = 0;
|
|
561
|
+
let eventByteLimitRecorded = false;
|
|
562
|
+
const factory = request.webSocket ?? ((socketUrl) => new WebSocket(socketUrl));
|
|
563
|
+
const connect = async (resendMode) => {
|
|
564
|
+
assertNotAborted(request.signal);
|
|
565
|
+
const url = new URL(`/sockets/events/${encodeURIComponent(conversationId)}`, baseUrl);
|
|
566
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
567
|
+
url.searchParams.set("resend_mode", resendMode);
|
|
568
|
+
if (resendMode === "since" && lastTimestamp !== undefined)
|
|
569
|
+
url.searchParams.set("after_timestamp", lastTimestamp);
|
|
570
|
+
const socket = factory(url.toString());
|
|
571
|
+
current = socket;
|
|
572
|
+
let active = true;
|
|
573
|
+
let rejectPendingOpen;
|
|
574
|
+
const onMessage = ({ data }) => {
|
|
575
|
+
if (!active)
|
|
576
|
+
return;
|
|
577
|
+
try {
|
|
578
|
+
const text = typeof data === "string" ? data : String(data);
|
|
579
|
+
const maxBytes = request.maxResponseBytes ?? OPENHANDS_DEFAULT_MAX_RESPONSE_BYTES;
|
|
580
|
+
const frameBytes = Buffer.byteLength(text);
|
|
581
|
+
if (frameBytes > maxBytes) {
|
|
582
|
+
onStatus({ state: "message-rejected", reason: `frame exceeds ${maxBytes} bytes` });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const value = JSON.parse(text);
|
|
586
|
+
if (isRecord(value)) {
|
|
587
|
+
const maxCaptureBytes = request.maxCaptureBytes ?? OPENHANDS_DEFAULT_MAX_CAPTURE_BYTES;
|
|
588
|
+
if (acceptedEventBytes + frameBytes > maxCaptureBytes) {
|
|
589
|
+
if (!eventByteLimitRecorded) {
|
|
590
|
+
eventByteLimitRecorded = true;
|
|
591
|
+
onStatus({ state: "event-byte-limit-reached", limit: maxCaptureBytes });
|
|
592
|
+
}
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
acceptedEventBytes += frameBytes;
|
|
596
|
+
if (typeof value.timestamp === "string")
|
|
597
|
+
lastTimestamp = value.timestamp;
|
|
598
|
+
onEvent(value);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
onStatus({ state: "message-rejected", reason: "frame is not a JSON object" });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
onStatus({ state: "message-rejected", reason: errorMessage(error).slice(0, 512) });
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const onClose = () => {
|
|
609
|
+
active = false;
|
|
610
|
+
if (rejectPendingOpen !== undefined) {
|
|
611
|
+
rejectPendingOpen(stopped
|
|
612
|
+
? new CaptureClosedSocket("OpenHands capture closed its pending WebSocket.")
|
|
613
|
+
: new Error("OpenHands event WebSocket closed before opening."));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (stopped)
|
|
617
|
+
return;
|
|
618
|
+
onStatus({ state: "disconnected", connection: reconnects + 1 });
|
|
619
|
+
if (reconnects >= (request.maxReconnects ?? 2)) {
|
|
620
|
+
onStatus({ state: "reconnect-exhausted", connection: reconnects + 1 });
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
reconnects += 1;
|
|
624
|
+
onStatus({ state: "reconnecting", connection: reconnects + 1 });
|
|
625
|
+
setTimeout(() => {
|
|
626
|
+
if (!stopped)
|
|
627
|
+
void connect(lastTimestamp === undefined ? "all" : "since").catch((error) => {
|
|
628
|
+
if (!(error instanceof CaptureClosedSocket)) {
|
|
629
|
+
onStatus({ state: "reconnect-failed", reason: errorMessage(error).slice(0, 512) });
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
}, request.reconnectDelayMs ?? 250);
|
|
633
|
+
};
|
|
634
|
+
socket.addEventListener("message", onMessage);
|
|
635
|
+
socket.addEventListener("close", onClose);
|
|
636
|
+
await new Promise((resolvePromise, reject) => {
|
|
637
|
+
let settled = false;
|
|
638
|
+
const cleanupPending = () => {
|
|
639
|
+
cancelPendingOpen = undefined;
|
|
640
|
+
rejectPendingOpen = undefined;
|
|
641
|
+
clearTimeout(timeout);
|
|
642
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
643
|
+
socket.removeEventListener("open", onOpen);
|
|
644
|
+
};
|
|
645
|
+
const fail = (error) => {
|
|
646
|
+
if (settled)
|
|
647
|
+
return;
|
|
648
|
+
settled = true;
|
|
649
|
+
active = false;
|
|
650
|
+
stopped = true;
|
|
651
|
+
cleanupPending();
|
|
652
|
+
socket.removeEventListener("message", onMessage);
|
|
653
|
+
socket.removeEventListener("close", onClose);
|
|
654
|
+
socket.close();
|
|
655
|
+
reject(error);
|
|
656
|
+
};
|
|
657
|
+
const onAbort = () => {
|
|
658
|
+
fail(new Error("OpenHands capture was aborted."));
|
|
659
|
+
};
|
|
660
|
+
const onOpen = () => {
|
|
661
|
+
if (settled)
|
|
662
|
+
return;
|
|
663
|
+
settled = true;
|
|
664
|
+
cleanupPending();
|
|
665
|
+
if (request.sessionApiKey !== undefined) {
|
|
666
|
+
socket.send(JSON.stringify({ type: "auth", session_api_key: request.sessionApiKey }));
|
|
667
|
+
}
|
|
668
|
+
onStatus({ state: "connected", connection: reconnects + 1, resendMode });
|
|
669
|
+
resolvePromise();
|
|
670
|
+
};
|
|
671
|
+
const timeout = setTimeout(() => fail(new Error("OpenHands event WebSocket did not open before timeout.")), request.timeoutMs ?? 30_000);
|
|
672
|
+
rejectPendingOpen = fail;
|
|
673
|
+
cancelPendingOpen = () => fail(new CaptureClosedSocket("OpenHands capture closed its pending WebSocket."));
|
|
674
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
675
|
+
socket.addEventListener("open", onOpen);
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
await connect("all");
|
|
679
|
+
const onAbort = () => {
|
|
680
|
+
stopped = true;
|
|
681
|
+
current?.close();
|
|
682
|
+
};
|
|
683
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
684
|
+
return {
|
|
685
|
+
get readyState() { return current?.readyState ?? 3; },
|
|
686
|
+
addEventListener: (...args) => current?.addEventListener(...args),
|
|
687
|
+
removeEventListener: (...args) => current?.removeEventListener(...args),
|
|
688
|
+
send: (data) => current?.send(data),
|
|
689
|
+
close: () => {
|
|
690
|
+
stopped = true;
|
|
691
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
692
|
+
cancelPendingOpen?.();
|
|
693
|
+
current?.close();
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
async function pollConversation(fetch, baseUrl, conversationId, request) {
|
|
698
|
+
const deadline = Date.now() + (request.timeoutMs ?? 300_000);
|
|
699
|
+
for (;;) {
|
|
700
|
+
const conversation = await requestJson(fetch, baseUrl, `/api/conversations/${encodeURIComponent(conversationId)}`, undefined, request.sessionApiKey, request.maxResponseBytes ?? OPENHANDS_DEFAULT_MAX_RESPONSE_BYTES, request.signal, request.timeoutMs);
|
|
701
|
+
if (typeof conversation.execution_status === "string" && TERMINAL_STATUSES.has(conversation.execution_status)) {
|
|
702
|
+
return conversation;
|
|
703
|
+
}
|
|
704
|
+
if (Date.now() >= deadline)
|
|
705
|
+
throw new Error("OpenHands conversation did not reach a terminal status before timeout.");
|
|
706
|
+
await abortableDelay(request.pollIntervalMs ?? 250, request.signal);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function readFinalEvents(fetch, baseUrl, conversationId, sessionApiKey, maxResponseBytes = OPENHANDS_DEFAULT_MAX_RESPONSE_BYTES, maxCaptureBytes = OPENHANDS_DEFAULT_MAX_CAPTURE_BYTES, signal, timeoutMs, onEvent) {
|
|
710
|
+
const events = [];
|
|
711
|
+
let capturedBytes = 0;
|
|
712
|
+
const pageIds = new Set();
|
|
713
|
+
let pageId;
|
|
714
|
+
do {
|
|
715
|
+
if (pageId !== undefined && pageIds.has(pageId))
|
|
716
|
+
throw new Error("OpenHands event pagination repeated a page cursor.");
|
|
717
|
+
if (pageId !== undefined)
|
|
718
|
+
pageIds.add(pageId);
|
|
719
|
+
const path = new URL(`/api/conversations/${encodeURIComponent(conversationId)}/events/search`, baseUrl);
|
|
720
|
+
path.searchParams.set("limit", "100");
|
|
721
|
+
path.searchParams.set("sort_order", "TIMESTAMP");
|
|
722
|
+
if (pageId !== undefined)
|
|
723
|
+
path.searchParams.set("page_id", pageId);
|
|
724
|
+
const page = await requestJson(fetch, baseUrl, path.toString(), undefined, sessionApiKey, maxResponseBytes, signal, timeoutMs);
|
|
725
|
+
if (!Array.isArray(page.items) || !page.items.every(isRecord))
|
|
726
|
+
throw new Error("OpenHands event search returned an invalid page.");
|
|
727
|
+
if (events.length + page.items.length > MAX_CAPTURE_EVENTS)
|
|
728
|
+
throw new Error(`OpenHands event capture exceeds ${MAX_CAPTURE_EVENTS} records.`);
|
|
729
|
+
for (const event of page.items) {
|
|
730
|
+
capturedBytes += Buffer.byteLength(JSON.stringify(event));
|
|
731
|
+
if (capturedBytes > maxCaptureBytes)
|
|
732
|
+
throw new Error(`OpenHands event capture exceeds ${maxCaptureBytes} bytes.`);
|
|
733
|
+
events.push(event);
|
|
734
|
+
onEvent?.(event, events.length);
|
|
735
|
+
}
|
|
736
|
+
pageId = typeof page.next_page_id === "string" && page.next_page_id.length > 0 ? page.next_page_id : undefined;
|
|
737
|
+
} while (pageId !== undefined);
|
|
738
|
+
return events;
|
|
739
|
+
}
|
|
740
|
+
async function requestJson(fetch, baseUrl, path, init, sessionApiKey, maxResponseBytes = OPENHANDS_DEFAULT_MAX_RESPONSE_BYTES, signal, timeoutMs = 30_000) {
|
|
741
|
+
const headers = new Headers(init?.headers);
|
|
742
|
+
headers.set("accept", "application/json");
|
|
743
|
+
if (init?.body !== undefined)
|
|
744
|
+
headers.set("content-type", "application/json");
|
|
745
|
+
if (sessionApiKey !== undefined)
|
|
746
|
+
headers.set("x-session-api-key", sessionApiKey);
|
|
747
|
+
const requestSignal = AbortSignal.any([
|
|
748
|
+
...[init?.signal, signal].filter((candidate) => candidate !== undefined),
|
|
749
|
+
AbortSignal.timeout(Math.min(timeoutMs, 2_147_483_647)),
|
|
750
|
+
]);
|
|
751
|
+
const response = await fetch(path.startsWith("http") ? path : new URL(path, baseUrl), {
|
|
752
|
+
...init,
|
|
753
|
+
headers,
|
|
754
|
+
signal: requestSignal,
|
|
755
|
+
redirect: "manual",
|
|
756
|
+
});
|
|
757
|
+
if (!response.ok)
|
|
758
|
+
throw new Error(`OpenHands request ${init?.method ?? "GET"} ${new URL(response.url || path, baseUrl).pathname} failed with ${response.status}.`);
|
|
759
|
+
const bytes = await readBoundedResponse(response, maxResponseBytes);
|
|
760
|
+
let value;
|
|
761
|
+
try {
|
|
762
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
throw new Error(`OpenHands response is not valid UTF-8 JSON: ${errorMessage(error)}`);
|
|
766
|
+
}
|
|
767
|
+
if (!isRecord(value))
|
|
768
|
+
throw new Error("OpenHands response is not a JSON object.");
|
|
769
|
+
return value;
|
|
770
|
+
}
|
|
771
|
+
async function readBoundedResponse(response, maxBytes) {
|
|
772
|
+
const declared = Number(response.headers.get("content-length"));
|
|
773
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
774
|
+
await response.body?.cancel();
|
|
775
|
+
throw new Error(`OpenHands response exceeds ${maxBytes} bytes.`);
|
|
776
|
+
}
|
|
777
|
+
if (response.body === null)
|
|
778
|
+
return new Uint8Array();
|
|
779
|
+
const reader = response.body.getReader();
|
|
780
|
+
const chunks = [];
|
|
781
|
+
let size = 0;
|
|
782
|
+
for (;;) {
|
|
783
|
+
const { done, value } = await reader.read();
|
|
784
|
+
if (done)
|
|
785
|
+
break;
|
|
786
|
+
size += value.byteLength;
|
|
787
|
+
if (size > maxBytes) {
|
|
788
|
+
await reader.cancel();
|
|
789
|
+
throw new Error(`OpenHands response exceeds ${maxBytes} bytes.`);
|
|
790
|
+
}
|
|
791
|
+
chunks.push(value);
|
|
792
|
+
}
|
|
793
|
+
const bytes = new Uint8Array(size);
|
|
794
|
+
let offset = 0;
|
|
795
|
+
for (const chunk of chunks) {
|
|
796
|
+
bytes.set(chunk, offset);
|
|
797
|
+
offset += chunk.byteLength;
|
|
798
|
+
}
|
|
799
|
+
return bytes;
|
|
800
|
+
}
|
|
801
|
+
function normalizedBaseUrl(value) {
|
|
802
|
+
const url = new URL(value);
|
|
803
|
+
if (!["http:", "https:"].includes(url.protocol))
|
|
804
|
+
throw new Error("OpenHands base URL must use HTTP or HTTPS.");
|
|
805
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") {
|
|
806
|
+
throw new Error("OpenHands base URL must not include credentials, query parameters, or a fragment.");
|
|
807
|
+
}
|
|
808
|
+
if (url.pathname !== "/")
|
|
809
|
+
throw new Error("OpenHands base URL must be a bare origin without a path prefix.");
|
|
810
|
+
return url;
|
|
811
|
+
}
|
|
812
|
+
function assertIntegerOption(value, minimum, label) {
|
|
813
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value < minimum)) {
|
|
814
|
+
throw new Error(`OpenHands ${label} must be a safe integer of at least ${minimum}.`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function assertNotAborted(signal) {
|
|
818
|
+
if (signal?.aborted)
|
|
819
|
+
throw new Error("OpenHands capture was aborted.");
|
|
820
|
+
}
|
|
821
|
+
async function abortableDelay(milliseconds, signal) {
|
|
822
|
+
assertNotAborted(signal);
|
|
823
|
+
await new Promise((resolvePromise, reject) => {
|
|
824
|
+
const onElapsed = () => {
|
|
825
|
+
signal?.removeEventListener("abort", onAbort);
|
|
826
|
+
resolvePromise();
|
|
827
|
+
};
|
|
828
|
+
const onAbort = () => {
|
|
829
|
+
clearTimeout(timer);
|
|
830
|
+
reject(new Error("OpenHands capture was aborted."));
|
|
831
|
+
};
|
|
832
|
+
const timer = setTimeout(onElapsed, milliseconds);
|
|
833
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
function eventIds(events) {
|
|
837
|
+
return [...new Set(events.map(nativeEventId).filter((id) => id !== undefined))];
|
|
838
|
+
}
|
|
839
|
+
function nativeEventId(event) {
|
|
840
|
+
return typeof event.id === "string" && event.id.length > 0 ? event.id : undefined;
|
|
841
|
+
}
|
|
842
|
+
function uniformEventId(nativeId) {
|
|
843
|
+
const eventId = `openhands:native:${nativeId}`;
|
|
844
|
+
return eventId.length <= 255 ? eventId : undefined;
|
|
845
|
+
}
|
|
846
|
+
function isOffsetDateTime(value) {
|
|
847
|
+
return typeof value === "string"
|
|
848
|
+
&& /(?:Z|[+-]\d{2}:\d{2})$/u.test(value)
|
|
849
|
+
&& Number.isFinite(Date.parse(value));
|
|
850
|
+
}
|
|
851
|
+
function referenceKey(reference) {
|
|
852
|
+
return JSON.stringify([reference.artifactId, reference.recordLocator]);
|
|
853
|
+
}
|
|
854
|
+
function isRecord(value) {
|
|
855
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
856
|
+
}
|
|
857
|
+
function errorMessage(error) {
|
|
858
|
+
return error instanceof Error ? error.message : String(error);
|
|
859
|
+
}
|