chore: import upstream snapshot with attribution
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Convert between OpenClaw's AgentMessage format and OpenAI message format.
|
||||
*
|
||||
* AgentMessage uses:
|
||||
* role: "user" | "assistant" | "toolResult"
|
||||
* content: string | ContentBlock[]
|
||||
*
|
||||
* OpenAI uses:
|
||||
* role: "user" | "assistant" | "system" | "tool"
|
||||
* content: string
|
||||
* tool_calls?: ToolCall[]
|
||||
* tool_call_id?: string
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface OpenAIMessage {
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_calls?: any[];
|
||||
tool_call_id?: string;
|
||||
name?: string;
|
||||
_headroomMeta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AgentMessage[] to OpenAI message format for compression.
|
||||
*/
|
||||
export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
|
||||
const result: OpenAIMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const normalized = normalizeAgentMessage(msg);
|
||||
const role = normalized.role;
|
||||
|
||||
const buildMeta = (): Record<string, unknown> => {
|
||||
const meta = { ...normalized } as Record<string, unknown>;
|
||||
delete meta.role;
|
||||
delete meta.content;
|
||||
return meta;
|
||||
};
|
||||
|
||||
if (role === "system") {
|
||||
result.push({
|
||||
role: "system",
|
||||
content:
|
||||
typeof normalized.content === "string"
|
||||
? normalized.content
|
||||
: extractText(normalized.content),
|
||||
_headroomMeta: buildMeta(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
result.push({
|
||||
role: "user",
|
||||
content:
|
||||
typeof normalized.content === "string"
|
||||
? normalized.content
|
||||
: extractText(normalized.content),
|
||||
_headroomMeta: buildMeta(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
const content = normalized.content;
|
||||
if (typeof content === "string") {
|
||||
result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content blocks: extract text and tool call blocks.
|
||||
// OpenClaw uses `toolCall`; some adapters still emit legacy `tool_use`.
|
||||
if (Array.isArray(content)) {
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: any[] = [];
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") {
|
||||
textParts.push(block);
|
||||
} else if (block.type === "text") {
|
||||
textParts.push(block.text);
|
||||
} else if (block.type === "tool_use" || block.type === "toolCall") {
|
||||
const args =
|
||||
block.type === "toolCall"
|
||||
? block.arguments
|
||||
: block.input;
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments:
|
||||
typeof args === "string"
|
||||
? args
|
||||
: JSON.stringify(args ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const openaiMsg: OpenAIMessage = {
|
||||
role: "assistant",
|
||||
content: textParts.length > 0 ? textParts.join("") : null,
|
||||
_headroomMeta: buildMeta(),
|
||||
};
|
||||
if (toolCalls.length > 0) {
|
||||
openaiMsg.tool_calls = toolCalls;
|
||||
}
|
||||
result.push(openaiMsg);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === "toolResult" || role === "tool_result") {
|
||||
const content =
|
||||
typeof normalized.content === "string"
|
||||
? normalized.content
|
||||
: Array.isArray(normalized.content)
|
||||
? extractText(normalized.content)
|
||||
: JSON.stringify(normalized.content);
|
||||
|
||||
result.push({
|
||||
role: "tool",
|
||||
content,
|
||||
tool_call_id:
|
||||
normalized.toolCallId ??
|
||||
normalized.tool_use_id ??
|
||||
normalized.id ??
|
||||
"unknown",
|
||||
_headroomMeta: buildMeta(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: pass through as user message
|
||||
result.push({
|
||||
role: "user",
|
||||
content:
|
||||
typeof normalized.content === "string"
|
||||
? normalized.content
|
||||
: JSON.stringify(normalized.content),
|
||||
_headroomMeta: buildMeta(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert compressed OpenAI messages back to AgentMessage format.
|
||||
*/
|
||||
export function openAIToAgent(messages: OpenAIMessage[]): any[] {
|
||||
const result: any[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
const meta = (msg._headroomMeta ?? {}) as Record<string, unknown>;
|
||||
const timestamp =
|
||||
typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
|
||||
|
||||
if (msg.role === "system") {
|
||||
result.push({
|
||||
role: "system",
|
||||
content: msg.content ?? "",
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content ?? "",
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const blocks: any[] = [];
|
||||
if (msg.content) {
|
||||
blocks.push({ type: "text", text: msg.content });
|
||||
}
|
||||
if (msg.tool_calls) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
let input: any;
|
||||
try {
|
||||
input = JSON.parse(tc.function.arguments);
|
||||
} catch {
|
||||
input = tc.function.arguments ?? {};
|
||||
}
|
||||
// Emit OpenClaw-native block shape so downstream transports keep call linkage.
|
||||
blocks.push({
|
||||
type: "toolCall",
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: input,
|
||||
});
|
||||
}
|
||||
}
|
||||
// OpenClaw's Pi agent expects content to always be an array for assistant messages
|
||||
// (it calls .flatMap() on it). Never flatten to a string.
|
||||
result.push({
|
||||
...(meta as object),
|
||||
role: "assistant",
|
||||
content: blocks,
|
||||
api: typeof meta.api === "string" ? meta.api : "headroom",
|
||||
provider: typeof meta.provider === "string" ? meta.provider : "headroom",
|
||||
model: typeof meta.model === "string" ? meta.model : "headroom",
|
||||
usage:
|
||||
isRecord(meta.usage)
|
||||
? meta.usage
|
||||
: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason:
|
||||
typeof meta.stopReason === "string" ? meta.stopReason : "stop",
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "tool") {
|
||||
const textContent =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: msg.content == null
|
||||
? ""
|
||||
: JSON.stringify(msg.content);
|
||||
const toolCallId = msg.tool_call_id ?? "unknown";
|
||||
result.push({
|
||||
...(meta as object),
|
||||
role: "toolResult",
|
||||
// OpenClaw transport layers expect toolResult content blocks, not a raw string.
|
||||
content: [{ type: "text", text: textContent }],
|
||||
toolCallId:
|
||||
typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
|
||||
tool_use_id:
|
||||
typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
|
||||
toolName:
|
||||
typeof meta.toolName === "string" ? meta.toolName : "headroom",
|
||||
isError: typeof meta.isError === "boolean" ? meta.isError : false,
|
||||
timestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeAgentMessages(messages: any[]): any[] {
|
||||
return messages.map((message) => normalizeAgentMessage(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from content blocks.
|
||||
*/
|
||||
function extractText(content: any): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return JSON.stringify(content);
|
||||
|
||||
return content
|
||||
.map((block: any) => {
|
||||
if (typeof block === "string") return block;
|
||||
if (block.type === "text") return block.text;
|
||||
if (block.type === "tool_result") {
|
||||
return typeof block.content === "string" ? block.content : JSON.stringify(block.content);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function normalizeAgentMessage(message: any): any {
|
||||
if (!isRecord(message)) return message;
|
||||
|
||||
if (message.role === "assistant") {
|
||||
return normalizeAssistantMessage(message);
|
||||
}
|
||||
|
||||
if (message.role === "toolResult" || message.role === "tool_result") {
|
||||
return normalizeToolResultMessage(message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function normalizeAssistantMessage(message: Record<string, any>): Record<string, any> {
|
||||
const normalizedContent = normalizeAssistantContent(message.content);
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: normalizedContent,
|
||||
api: typeof message.api === "string" ? message.api : "headroom",
|
||||
provider: typeof message.provider === "string" ? message.provider : "headroom",
|
||||
model: typeof message.model === "string" ? message.model : "headroom",
|
||||
usage: isRecord(message.usage)
|
||||
? message.usage
|
||||
: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
|
||||
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolResultMessage(message: Record<string, any>): Record<string, any> {
|
||||
const normalizedContent = normalizeToolResultContent(message.content);
|
||||
const toolCallId =
|
||||
typeof message.toolCallId === "string"
|
||||
? message.toolCallId
|
||||
: typeof message.tool_use_id === "string"
|
||||
? message.tool_use_id
|
||||
: typeof message.id === "string"
|
||||
? message.id
|
||||
: "unknown";
|
||||
|
||||
return {
|
||||
...message,
|
||||
role: "toolResult",
|
||||
content: normalizedContent,
|
||||
toolCallId,
|
||||
tool_use_id:
|
||||
typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
|
||||
toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
|
||||
isError: typeof message.isError === "boolean" ? message.isError : false,
|
||||
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAssistantContent(content: unknown): any[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content.flatMap((block) => {
|
||||
if (typeof block === "string") return [{ type: "text", text: block }];
|
||||
if (!isRecord(block) || typeof block.type !== "string") return [];
|
||||
if (block.type === "text" && typeof block.text === "string") return [block];
|
||||
if (block.type === "thinking" && typeof block.thinking === "string") return [block];
|
||||
if (
|
||||
(block.type === "toolCall" || block.type === "tool_use") &&
|
||||
typeof block.name === "string"
|
||||
) {
|
||||
return [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: typeof block.id === "string" ? block.id : "unknown",
|
||||
name: block.name,
|
||||
arguments:
|
||||
"arguments" in block
|
||||
? block.arguments
|
||||
: "input" in block
|
||||
? block.input
|
||||
: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
|
||||
if (content == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ type: "text", text: JSON.stringify(content) }];
|
||||
}
|
||||
|
||||
function normalizeToolResultContent(content: unknown): any[] {
|
||||
if (Array.isArray(content)) {
|
||||
return content.flatMap((block) => {
|
||||
if (typeof block === "string") return [{ type: "text", text: block }];
|
||||
if (!isRecord(block) || typeof block.type !== "string") return [];
|
||||
if (block.type === "text" && typeof block.text === "string") return [block];
|
||||
if (
|
||||
block.type === "image" &&
|
||||
typeof block.data === "string" &&
|
||||
typeof block.mimeType === "string"
|
||||
) {
|
||||
return [block];
|
||||
}
|
||||
if (block.type === "tool_result" && "content" in block) {
|
||||
return normalizeToolResultContent(block.content);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
|
||||
if (content == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ type: "text", text: JSON.stringify(content) }];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* HeadroomContextEngine — ContextEngine implementation for OpenClaw.
|
||||
*
|
||||
* Compresses tool outputs and conversation context using the Headroom proxy.
|
||||
* Zero LLM calls — all compression is algorithmic (SmartCrusher, ContentRouter, etc.)
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { compress } from "headroom-ai";
|
||||
import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js";
|
||||
import { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
|
||||
|
||||
export interface HeadroomEngineConfig extends ProxyManagerConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class HeadroomContextEngine {
|
||||
readonly info = {
|
||||
id: "headroom",
|
||||
name: "Headroom Context Compression",
|
||||
version: "0.1.0",
|
||||
ownsCompaction: true,
|
||||
};
|
||||
|
||||
private proxyManager: ProxyManager;
|
||||
private proxyUrl: string | null = null;
|
||||
private config: HeadroomEngineConfig;
|
||||
private logger: ProxyManagerLogger;
|
||||
private proxyReadyListeners = new Set<(proxyUrl: string) => void | Promise<void>>();
|
||||
private proxyStartupPromise: Promise<string> | null = null;
|
||||
private proxyStartupError: unknown = null;
|
||||
private stats = {
|
||||
totalCompressions: 0,
|
||||
totalTokensSaved: 0,
|
||||
totalTokensBefore: 0,
|
||||
compactions: 0,
|
||||
};
|
||||
|
||||
constructor(config: HeadroomEngineConfig = {}, logger?: ProxyManagerLogger) {
|
||||
this.config = config;
|
||||
this.logger = logger ?? defaultLogger;
|
||||
this.proxyManager = new ProxyManager(config, this.logger);
|
||||
}
|
||||
|
||||
// === ContextEngine Lifecycle ===
|
||||
|
||||
async bootstrap(params: {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionFile: string;
|
||||
}): Promise<{ bootstrapped: boolean; reason?: string }> {
|
||||
if (this.config.enabled === false) {
|
||||
return { bootstrapped: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
this.ensureProxyStarted();
|
||||
return { bootstrapped: true, reason: "proxy startup scheduled" };
|
||||
}
|
||||
|
||||
async ingest(params: {
|
||||
sessionId: string;
|
||||
message: any;
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<{ ingested: boolean }> {
|
||||
// No-op: OpenClaw's runtime stores messages. We don't need a separate store.
|
||||
return { ingested: true };
|
||||
}
|
||||
|
||||
async ingestBatch?(params: {
|
||||
sessionId: string;
|
||||
messages: any[];
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<{ ingestedCount: number }> {
|
||||
return { ingestedCount: params.messages.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble context for the model — THE CORE HOOK.
|
||||
*
|
||||
* Converts AgentMessage[] → OpenAI format → compress() → AgentMessage[]
|
||||
*/
|
||||
async assemble(params: {
|
||||
sessionId: string;
|
||||
messages: any[];
|
||||
tokenBudget?: number;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
}): Promise<{
|
||||
messages: any[];
|
||||
estimatedTokens: number;
|
||||
systemPromptAddition?: string;
|
||||
}> {
|
||||
if (!this.proxyUrl || this.config.enabled === false) {
|
||||
this.ensureProxyStarted();
|
||||
// Fallback: return messages unchanged
|
||||
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert AgentMessage → OpenAI format
|
||||
const openaiMessages = agentToOpenAI(params.messages);
|
||||
|
||||
// Compress via proxy — pass tokenBudget so RollingWindow enforces it
|
||||
const result = await compress(openaiMessages, {
|
||||
model: params.model ?? "claude-sonnet-4-5",
|
||||
baseUrl: this.proxyUrl,
|
||||
fallback: true,
|
||||
tokenBudget: params.tokenBudget,
|
||||
} as any);
|
||||
|
||||
if (!result.compressed || result.tokensSaved === 0) {
|
||||
return {
|
||||
messages: normalizeAgentMessages(params.messages),
|
||||
estimatedTokens: result.tokensBefore,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert back to AgentMessage format
|
||||
const compressedAgentMessages = openAIToAgent(result.messages);
|
||||
|
||||
// Track stats
|
||||
this.stats.totalCompressions++;
|
||||
this.stats.totalTokensSaved += result.tokensSaved;
|
||||
this.stats.totalTokensBefore += result.tokensBefore;
|
||||
|
||||
this.logger.debug(
|
||||
`Assembled: ${result.tokensBefore} → ${result.tokensAfter} tokens (saved ${result.tokensSaved})`,
|
||||
);
|
||||
|
||||
return {
|
||||
messages: compressedAgentMessages,
|
||||
estimatedTokens: result.tokensAfter,
|
||||
systemPromptAddition:
|
||||
result.tokensSaved > 100
|
||||
? `[Context compressed by Headroom: ${result.tokensSaved} tokens saved. Use headroom_retrieve with the hash to get full details.]`
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Assemble failed: ${error}`);
|
||||
// Graceful fallback: return original messages
|
||||
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact context — zero-cost alternative to LLM summarization.
|
||||
*
|
||||
* Calls compress() with the token budget, which triggers:
|
||||
* - SmartCrusher: aggressive JSON compression (70-90% on tool outputs)
|
||||
* - Kompress: ModernBERT text compression (40-60% on assistant text)
|
||||
* - RollingWindow: drops oldest messages if still over budget
|
||||
* - CCR: stores originals for retrieval via headroom_retrieve tool
|
||||
*
|
||||
* Zero LLM calls. All algorithmic.
|
||||
*/
|
||||
async compact(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
tokenBudget?: number;
|
||||
force?: boolean;
|
||||
runtimeContext?: any;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
compacted: boolean;
|
||||
reason?: string;
|
||||
result?: {
|
||||
tokensBefore: number;
|
||||
tokensAfter?: number;
|
||||
};
|
||||
}> {
|
||||
if (!this.proxyUrl) {
|
||||
return { ok: false, compacted: false, reason: "Proxy not available" };
|
||||
}
|
||||
|
||||
// Read current messages from session file if available
|
||||
// For now, compact() works in tandem with assemble() — the next assemble()
|
||||
// call will compress with the token budget. When compact() is called
|
||||
// independently, we report success since our pipeline handles it.
|
||||
//
|
||||
// TODO: Read session file, extract messages, call compress() with tokenBudget,
|
||||
// write back compacted messages.
|
||||
|
||||
this.stats.compactions++;
|
||||
this.logger.info(
|
||||
`Compact called (budget: ${params.tokenBudget ?? "none"}, force: ${params.force ?? false})`,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "Headroom applies SmartCrusher + Kompress + RollingWindow on next assemble()",
|
||||
};
|
||||
}
|
||||
|
||||
async afterTurn?(params: {
|
||||
sessionId: string;
|
||||
messages: any[];
|
||||
prePromptMessageCount: number;
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<void> {
|
||||
// Optional: could log stats or trigger learning
|
||||
}
|
||||
|
||||
async prepareSubagentSpawn?(params: {
|
||||
parentSessionKey: string;
|
||||
childSessionKey: string;
|
||||
ttlMs?: number;
|
||||
}): Promise<{ rollback: () => Promise<void> } | undefined> {
|
||||
// Subagent context is compressed naturally via assemble()
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async onSubagentEnded?(params: {
|
||||
childSessionKey: string;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
// No-op
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.proxyManager.stop();
|
||||
this.logger.info(
|
||||
`Engine disposed. Stats: ${this.stats.totalCompressions} compressions, ` +
|
||||
`${this.stats.totalTokensSaved} tokens saved`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
getProxyUrl(): string | null {
|
||||
return this.proxyUrl;
|
||||
}
|
||||
|
||||
getProxyStartupError(): unknown {
|
||||
return this.proxyStartupError;
|
||||
}
|
||||
|
||||
ensureProxyStarted(): void {
|
||||
if (this.config.enabled === false || this.proxyUrl || this.proxyStartupPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.proxyStartupError = null;
|
||||
this.proxyStartupPromise = this.proxyManager
|
||||
.start()
|
||||
.then(async (proxyUrl) => {
|
||||
this.proxyUrl = proxyUrl;
|
||||
this.proxyStartupError = null;
|
||||
await this.notifyProxyReady(proxyUrl);
|
||||
this.logger.info(`Headroom proxy ready at ${proxyUrl}`);
|
||||
return proxyUrl;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.proxyStartupError = error;
|
||||
this.logger.warn(`Headroom proxy unavailable: ${error}`);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.proxyStartupPromise = null;
|
||||
});
|
||||
|
||||
// Fire-and-forget lifecycle callers intentionally do not await this promise.
|
||||
// Keep the promise rejectable for ensureProxyUrl(), but mark it observed so
|
||||
// a missing proxy cannot become a process-level unhandled rejection.
|
||||
void this.proxyStartupPromise.catch(() => {});
|
||||
}
|
||||
|
||||
onProxyReady(listener: (proxyUrl: string) => void | Promise<void>): () => void {
|
||||
this.proxyReadyListeners.add(listener);
|
||||
return () => {
|
||||
this.proxyReadyListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async ensureProxyUrl(): Promise<string> {
|
||||
if (this.proxyUrl) {
|
||||
return this.proxyUrl;
|
||||
}
|
||||
|
||||
this.ensureProxyStarted();
|
||||
if (!this.proxyStartupPromise) {
|
||||
throw new Error("Headroom proxy startup is disabled");
|
||||
}
|
||||
return this.proxyStartupPromise;
|
||||
}
|
||||
|
||||
private async notifyProxyReady(proxyUrl: string): Promise<void> {
|
||||
for (const listener of this.proxyReadyListeners) {
|
||||
try {
|
||||
await listener(proxyUrl);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Headroom proxy ready listener failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export const DEFAULT_GATEWAY_PROVIDER_IDS = ["openai-codex"] as const;
|
||||
|
||||
const DEFAULT_PROVIDER_BASE_URLS: Readonly<Record<string, string>> = {
|
||||
"openai-codex": "https://chatgpt.com/backend-api",
|
||||
};
|
||||
|
||||
const GATEWAY_PROVIDER_ID_ALIASES: Readonly<Record<string, string>> = {
|
||||
codex: "openai-codex",
|
||||
claude: "anthropic",
|
||||
copilot: "github-copilot",
|
||||
gemini: "google",
|
||||
};
|
||||
|
||||
const EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS = new Set<string>(["github-copilot"]);
|
||||
|
||||
export function resolveGatewayProviderIds(config: Record<string, unknown> | undefined): string[] {
|
||||
const configuredProviderIds = normalizeGatewayProviderIds(config?.gatewayProviderIds);
|
||||
if (configuredProviderIds.length > 0) {
|
||||
return configuredProviderIds;
|
||||
}
|
||||
|
||||
if (config?.routeCodexViaProxy === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...DEFAULT_GATEWAY_PROVIDER_IDS];
|
||||
}
|
||||
|
||||
function normalizeGatewayProviderIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawProviderId = entry.trim();
|
||||
const providerId = GATEWAY_PROVIDER_ID_ALIASES[rawProviderId.toLowerCase()] ?? rawProviderId;
|
||||
if (!providerId || seen.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(providerId);
|
||||
normalized.push(providerId);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function applyGatewayProviderBaseUrls<T>(
|
||||
cfg: T,
|
||||
proxyUrl: string,
|
||||
providerIds: readonly string[],
|
||||
): { changed: boolean; config: T } {
|
||||
const next = structuredClone((cfg ?? {}) as any);
|
||||
const changed = applyGatewayProviderBaseUrlsInPlace(next, proxyUrl, providerIds);
|
||||
return { changed, config: next as T };
|
||||
}
|
||||
|
||||
export function applyGatewayProviderBaseUrlsInPlace(
|
||||
cfg: any,
|
||||
proxyUrl: string,
|
||||
providerIds: readonly string[],
|
||||
): boolean {
|
||||
if (!cfg || typeof cfg !== "object" || providerIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const models = (cfg.models ??= {});
|
||||
const providers = (models.providers ??= {});
|
||||
let changed = false;
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const currentValue = providers[providerId];
|
||||
const currentConfig =
|
||||
currentValue && typeof currentValue === "object" && !Array.isArray(currentValue)
|
||||
? currentValue
|
||||
: {};
|
||||
const nextConfig = { ...currentConfig };
|
||||
const currentBaseUrl =
|
||||
typeof nextConfig.baseUrl === "string" && nextConfig.baseUrl.trim().length > 0
|
||||
? nextConfig.baseUrl
|
||||
: undefined;
|
||||
const defaultBaseUrl = DEFAULT_PROVIDER_BASE_URLS[providerId];
|
||||
if (
|
||||
!currentBaseUrl &&
|
||||
!defaultBaseUrl &&
|
||||
EXPLICIT_BASE_URL_REQUIRED_PROVIDER_IDS.has(providerId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const nextBaseUrl = routeBaseUrlThroughProxy({
|
||||
providerId,
|
||||
proxyUrl,
|
||||
currentBaseUrl,
|
||||
});
|
||||
|
||||
if (!Array.isArray(nextConfig.models)) {
|
||||
nextConfig.models = [];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (nextConfig.baseUrl === nextBaseUrl) {
|
||||
providers[providerId] = nextConfig;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextConfig.baseUrl = nextBaseUrl;
|
||||
providers[providerId] = nextConfig;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function routeBaseUrlThroughProxy(params: {
|
||||
providerId: string;
|
||||
proxyUrl: string;
|
||||
currentBaseUrl?: string;
|
||||
}): string {
|
||||
const upstreamBaseUrl = params.currentBaseUrl ?? DEFAULT_PROVIDER_BASE_URLS[params.providerId];
|
||||
if (!upstreamBaseUrl) {
|
||||
return params.proxyUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const proxy = new URL(params.proxyUrl);
|
||||
const upstream = new URL(upstreamBaseUrl);
|
||||
proxy.pathname = upstream.pathname;
|
||||
proxy.search = upstream.search;
|
||||
proxy.hash = "";
|
||||
return proxy.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return params.proxyUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { default } from "./plugin/index.js";
|
||||
export { HeadroomContextEngine } from "./engine.js";
|
||||
export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
|
||||
export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
|
||||
export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
|
||||
export {
|
||||
DEFAULT_GATEWAY_PROVIDER_IDS,
|
||||
applyGatewayProviderBaseUrls,
|
||||
applyGatewayProviderBaseUrlsInPlace,
|
||||
resolveGatewayProviderIds,
|
||||
} from "./gateway-config.js";
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool.
|
||||
*
|
||||
* Usage:
|
||||
* openclaw plugins install headroom-ai/openclaw
|
||||
*
|
||||
* Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
|
||||
* {
|
||||
* "plugins": {
|
||||
* "slots": { "contextEngine": "headroom" },
|
||||
* "entries": { "headroom": { "enabled": true } }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { HeadroomContextEngine } from "../engine.js";
|
||||
import {
|
||||
applyGatewayProviderBaseUrlsInPlace,
|
||||
resolveGatewayProviderIds,
|
||||
} from "../gateway-config.js";
|
||||
import { normalizeAndValidateProxyUrl, probeHeadroomProxy } from "../proxy-manager.js";
|
||||
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
|
||||
|
||||
/**
|
||||
* OpenClaw 2026.x plugin API requires a `{ register(api) }` object export.
|
||||
* The previous bare-function default export was silently skipped by the loader.
|
||||
* See: https://github.com/chopratejas/headroom/issues/XXX
|
||||
*/
|
||||
export default {
|
||||
register: headroomPlugin,
|
||||
};
|
||||
|
||||
function headroomPlugin(api: any) {
|
||||
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
|
||||
const logger = api.logger ?? console;
|
||||
const rawProxyUrl = config.proxyUrl;
|
||||
const proxyUrl =
|
||||
typeof rawProxyUrl === "string" && rawProxyUrl.trim().length > 0
|
||||
? normalizeAndValidateProxyUrl(rawProxyUrl)
|
||||
: undefined;
|
||||
|
||||
const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
|
||||
info: (m: string) => logger.info(m),
|
||||
warn: (m: string) => logger.warn(m),
|
||||
error: (m: string) => logger.error(m),
|
||||
debug: (m: string) => logger.debug?.(m),
|
||||
});
|
||||
const gatewayProviderIds = resolveGatewayProviderIds(config);
|
||||
let validatedConfiguredProxyUrl: string | null = null;
|
||||
let configuredProxyProbePromise: Promise<string | null> | null = null;
|
||||
|
||||
const applyGatewayRouting = async (activeProxyUrl: string) => {
|
||||
if (gatewayProviderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const changed = applyGatewayProviderBaseUrlsInPlace(api.config, activeProxyUrl, gatewayProviderIds);
|
||||
|
||||
if (changed) {
|
||||
logger.info(
|
||||
`[headroom] Routed ${gatewayProviderIds.join(", ")} through Headroom proxy in memory at ${activeProxyUrl}`,
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[headroom] Upstream gateway already routed in memory for ${gatewayProviderIds.join(", ")} at ${activeProxyUrl}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[headroom] Failed to configure upstream gateway routing: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getConfiguredRoutingProxyUrl = async (): Promise<string | null> => {
|
||||
if (!proxyUrl) {
|
||||
return null;
|
||||
}
|
||||
if (validatedConfiguredProxyUrl === proxyUrl) {
|
||||
return validatedConfiguredProxyUrl;
|
||||
}
|
||||
if (!configuredProxyProbePromise) {
|
||||
configuredProxyProbePromise = probeHeadroomProxy(proxyUrl)
|
||||
.then((probe) => {
|
||||
if (probe.reachable && probe.isHeadroom) {
|
||||
validatedConfiguredProxyUrl = proxyUrl;
|
||||
return proxyUrl;
|
||||
}
|
||||
logger.warn(
|
||||
`[headroom] Skipping upstream gateway routing: configured proxyUrl is not a ready Headroom proxy at ${proxyUrl}` +
|
||||
(probe.reason ? ` (${probe.reason})` : ""),
|
||||
);
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
`[headroom] Skipping upstream gateway routing: failed to probe configured proxyUrl ${proxyUrl}: ${error}`,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
configuredProxyProbePromise = null;
|
||||
});
|
||||
}
|
||||
return configuredProxyProbePromise;
|
||||
};
|
||||
|
||||
const ensureGatewayRouting = async () => {
|
||||
if (gatewayProviderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const activeProxyUrl = engine.getProxyUrl() ?? (await getConfiguredRoutingProxyUrl());
|
||||
if (!activeProxyUrl) {
|
||||
logger.debug?.("[headroom] Deferring upstream gateway routing until proxy is available");
|
||||
return;
|
||||
}
|
||||
await applyGatewayRouting(activeProxyUrl);
|
||||
};
|
||||
|
||||
engine.onProxyReady(async (activeProxyUrl) => {
|
||||
await applyGatewayRouting(activeProxyUrl);
|
||||
});
|
||||
|
||||
// Register as context engine
|
||||
api.registerContextEngine("headroom", () => engine);
|
||||
|
||||
// Register CCR retrieval tool (active once proxy is running)
|
||||
api.registerTool((ctx: any) => {
|
||||
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
|
||||
if (!activeProxyUrl) return null;
|
||||
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
|
||||
}, { names: ["headroom_retrieve"] });
|
||||
|
||||
api.on("gateway_start", async () => {
|
||||
await ensureGatewayRouting();
|
||||
});
|
||||
|
||||
void ensureGatewayRouting();
|
||||
|
||||
logger.info("[headroom] Plugin registered");
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Manages connectivity to a Headroom proxy (local or remote).
|
||||
*
|
||||
* Security model:
|
||||
* - Local proxies (127.0.0.1 / localhost) can be auto-started via subprocess
|
||||
* - Remote proxies are connect-only: probe and use, never launch
|
||||
* - No environment variable access
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import os from "os";
|
||||
|
||||
export interface ProxyManagerConfig {
|
||||
proxyUrl?: string;
|
||||
proxyPort?: number;
|
||||
pythonPath?: string;
|
||||
autoStart?: boolean;
|
||||
startupTimeoutMs?: number;
|
||||
retryMaxAttempts?: number;
|
||||
connectTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
export interface ProxyManagerLogger {
|
||||
info(message: string): void;
|
||||
warn(message: string): void;
|
||||
error(message: string): void;
|
||||
debug(message: string): void;
|
||||
}
|
||||
|
||||
/** Default logger that prefixes all messages with `[headroom]`. */
|
||||
export const defaultLogger: ProxyManagerLogger = {
|
||||
info: (m) => console.log(`[headroom] ${m}`),
|
||||
warn: (m) => console.warn(`[headroom] ${m}`),
|
||||
error: (m) => console.error(`[headroom] ${m}`),
|
||||
debug: () => {},
|
||||
};
|
||||
|
||||
export interface ProxyProbeResult {
|
||||
reachable: boolean;
|
||||
isHeadroom: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface LaunchSpec {
|
||||
label: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
checkCommand: string;
|
||||
checkArgs: string[];
|
||||
useShell?: boolean;
|
||||
checkUseShell?: boolean;
|
||||
}
|
||||
|
||||
const HEADROOM_MODULE_DISCOVERY_SNIPPET =
|
||||
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('headroom') else 1)";
|
||||
|
||||
export class ProxyManager {
|
||||
private config: ProxyManagerConfig;
|
||||
private logger: ProxyManagerLogger;
|
||||
private proxyUrl: string | null = null;
|
||||
|
||||
constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
|
||||
this.config = config;
|
||||
this.logger = logger ?? defaultLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a proxy is available. Returns the normalized URL origin.
|
||||
*/
|
||||
async start(): Promise<string> {
|
||||
const port = this.getProxyPort();
|
||||
const rawExplicitUrl =
|
||||
typeof this.config.proxyUrl === "string" && this.config.proxyUrl.trim().length > 0
|
||||
? normalizeAndValidateProxyUrl(this.config.proxyUrl)
|
||||
: null;
|
||||
// Only apply proxyPort default to local URLs — remote URLs use their protocol default
|
||||
const explicitUrl = rawExplicitUrl
|
||||
? isLocalProxyUrl(rawExplicitUrl) ? withDefaultPort(rawExplicitUrl, port) : rawExplicitUrl
|
||||
: null;
|
||||
const defaultCandidates = this.getDefaultProxyCandidates(port);
|
||||
const candidateUrls = explicitUrl ? [explicitUrl] : [...defaultCandidates];
|
||||
const probeByUrl = new Map<string, ProxyProbeResult>();
|
||||
|
||||
for (const url of candidateUrls) {
|
||||
const probe = await probeHeadroomProxy(url);
|
||||
probeByUrl.set(url, probe);
|
||||
if (probe.reachable && probe.isHeadroom) {
|
||||
this.proxyUrl = url;
|
||||
this.logger.info(`Headroom proxy already running at ${url}`);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
if (explicitUrl) {
|
||||
const explicitProbe = probeByUrl.get(explicitUrl);
|
||||
if (explicitProbe?.reachable && !explicitProbe.isHeadroom) {
|
||||
throw new Error(
|
||||
`Service reachable at ${explicitUrl}, but it does not appear to be a Headroom proxy (${explicitProbe.reason ?? "unknown service"}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote URLs are connect-only — never auto-start a subprocess for them
|
||||
if (explicitUrl && !isLocalProxyUrl(explicitUrl)) {
|
||||
throw new Error(
|
||||
`Remote Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running at that address.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-start is only available for local proxies
|
||||
if (this.config.autoStart === true) {
|
||||
const startupUrl = explicitUrl ?? defaultCandidates[0];
|
||||
const startupProbe = probeByUrl.get(startupUrl);
|
||||
if (startupProbe?.reachable && !startupProbe.isHeadroom) {
|
||||
throw new Error(
|
||||
`Cannot auto-start Headroom at ${startupUrl}: port is in use by a non-Headroom service (${startupProbe.reason ?? "unknown service"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
`No Headroom proxy detected${explicitUrl ? ` at ${startupUrl}` : " on default local endpoints"}; attempting to auto-start...`,
|
||||
);
|
||||
await this.startHeadroomProxy(startupUrl, port);
|
||||
|
||||
const startedProbe = await waitForHeadroomProxy(
|
||||
startupUrl,
|
||||
this.config.startupTimeoutMs ?? 20_000,
|
||||
);
|
||||
if (startedProbe.reachable && startedProbe.isHeadroom) {
|
||||
this.proxyUrl = startupUrl;
|
||||
this.logger.info(`Headroom proxy started and reachable at ${startupUrl}`);
|
||||
return startupUrl;
|
||||
}
|
||||
throw new Error(
|
||||
`Attempted to start Headroom proxy, but it was not reachable at ${startupUrl} (${startedProbe.reason ?? "unknown"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (explicitUrl) {
|
||||
throw new Error(
|
||||
`Headroom proxy not reachable at ${explicitUrl}. Ensure the proxy is running first.`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Headroom proxy not detected on default endpoints (${defaultCandidates.join(", ")}). ` +
|
||||
"Set proxyUrl explicitly or enable autoStart.",
|
||||
);
|
||||
}
|
||||
|
||||
private getProxyPort(): number {
|
||||
const rawPort = this.config.proxyPort;
|
||||
if (!Number.isInteger(rawPort) || rawPort === undefined) return 8787;
|
||||
if (rawPort < 1 || rawPort > 65535) {
|
||||
throw new Error("proxyPort must be an integer between 1 and 65535");
|
||||
}
|
||||
return rawPort;
|
||||
}
|
||||
|
||||
private getDefaultProxyCandidates(port: number): string[] {
|
||||
return [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop manager state. Spawned proxy processes are detached and externally managed.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.proxyUrl = null;
|
||||
}
|
||||
|
||||
getUrl(): string | null {
|
||||
return this.proxyUrl;
|
||||
}
|
||||
|
||||
// --- Internal ---
|
||||
|
||||
private async startHeadroomProxy(proxyUrl: string, defaultPort: number): Promise<void> {
|
||||
const parsed = new URL(proxyUrl);
|
||||
const host = parsed.hostname;
|
||||
const port = parsed.port || String(defaultPort);
|
||||
const specs = this.buildLaunchSpecs(host, port);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const spec of specs) {
|
||||
if (!this.canExecute(spec.checkCommand, spec.checkArgs, spec.checkUseShell ?? spec.useShell)) {
|
||||
this.logger.debug(`Launcher unavailable: ${spec.label}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
detached: true,
|
||||
shell: spec.useShell === true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
this.logger.info(`Auto-start launcher selected: ${spec.label}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
errors.push(`${spec.label}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No usable Headroom launcher found. Tried PATH, local npm, global npm, and Python. " +
|
||||
"Install headroom-ai (npm or pip) and ensure one launcher is available.\n" +
|
||||
(errors.length > 0 ? `Launch errors: ${errors.join("; ")}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
private buildLaunchSpecs(host: string, port: string): LaunchSpec[] {
|
||||
const commonArgs = ["proxy", "--host", host, "--port", port];
|
||||
const retryMaxAttempts = this.config.retryMaxAttempts;
|
||||
if (Number.isInteger(retryMaxAttempts)) {
|
||||
commonArgs.push("--retry-max-attempts", String(retryMaxAttempts));
|
||||
}
|
||||
|
||||
const connectTimeoutSeconds = this.config.connectTimeoutSeconds;
|
||||
if (Number.isInteger(connectTimeoutSeconds)) {
|
||||
commonArgs.push("--connect-timeout-seconds", String(connectTimeoutSeconds));
|
||||
}
|
||||
|
||||
const specs: LaunchSpec[] = [];
|
||||
|
||||
const configuredPython = this.getConfiguredPythonCommand();
|
||||
if (configuredPython) {
|
||||
specs.push({
|
||||
label: `Configured Python: ${configuredPython} -m headroom.cli`,
|
||||
command: configuredPython,
|
||||
args: ["-m", "headroom.cli", ...commonArgs],
|
||||
checkCommand: configuredPython,
|
||||
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Windows pyenv: resolve the real executable so we avoid shim .bat wrappers.
|
||||
if (process.platform === "win32") {
|
||||
const pyenvHeadroom = this.getPyenvResolvedHeadroom();
|
||||
if (pyenvHeadroom) {
|
||||
specs.push({
|
||||
label: `pyenv: ${pyenvHeadroom}`,
|
||||
command: pyenvHeadroom,
|
||||
args: commonArgs,
|
||||
checkCommand: pyenvHeadroom,
|
||||
checkArgs: ["--version"],
|
||||
useShell: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) PATH
|
||||
specs.push({
|
||||
label: "PATH: headroom",
|
||||
command: "headroom",
|
||||
args: commonArgs,
|
||||
checkCommand: process.platform === "win32" ? "where.exe" : "sh",
|
||||
checkArgs: process.platform === "win32"
|
||||
? ["headroom"]
|
||||
: ["-c", "command -v headroom >/dev/null 2>&1"],
|
||||
useShell: process.platform === "win32",
|
||||
checkUseShell: false,
|
||||
});
|
||||
|
||||
// 4) uv tool install path (~/.local/bin/headroom)
|
||||
const uvBin = join(
|
||||
os.homedir(),
|
||||
".local", "bin", "headroom"
|
||||
);
|
||||
if (existsSync(uvBin)) {
|
||||
specs.push({
|
||||
label: `uv tool: ${uvBin}`,
|
||||
command: uvBin,
|
||||
args: commonArgs,
|
||||
checkCommand: uvBin,
|
||||
checkArgs: ["--version"],
|
||||
});
|
||||
}
|
||||
|
||||
// 5) Local npm install (inside plugin install path)
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url)); // .../dist
|
||||
const packageRoot = dirname(moduleDir);
|
||||
const localBinDir = join(packageRoot, "node_modules", ".bin");
|
||||
const localBins = process.platform === "win32"
|
||||
? [join(localBinDir, "headroom.cmd"), join(localBinDir, "headroom")]
|
||||
: [join(localBinDir, "headroom")];
|
||||
for (const localBin of localBins) {
|
||||
if (!existsSync(localBin)) continue;
|
||||
specs.push({
|
||||
label: `Local npm: ${localBin}`,
|
||||
command: localBin,
|
||||
args: commonArgs,
|
||||
checkCommand: localBin,
|
||||
checkArgs: ["--version"],
|
||||
useShell: process.platform === "win32",
|
||||
});
|
||||
}
|
||||
|
||||
// 5) Global npm install
|
||||
const npmPrefix = this.getNpmGlobalPrefix();
|
||||
if (npmPrefix) {
|
||||
const globalBins = process.platform === "win32"
|
||||
? [join(npmPrefix, "headroom.cmd"), join(npmPrefix, "headroom")]
|
||||
: [join(npmPrefix, "bin", "headroom"), join(npmPrefix, "headroom")];
|
||||
|
||||
for (const globalBin of globalBins) {
|
||||
if (!existsSync(globalBin)) continue;
|
||||
specs.push({
|
||||
label: `Global npm: ${globalBin}`,
|
||||
command: globalBin,
|
||||
args: commonArgs,
|
||||
checkCommand: globalBin,
|
||||
checkArgs: ["--version"],
|
||||
useShell: process.platform === "win32",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 6) Python module fallback
|
||||
const pythonCommands = this.getPythonCommands();
|
||||
for (const pyCmd of pythonCommands) {
|
||||
if (configuredPython && pyCmd === configuredPython) continue;
|
||||
specs.push({
|
||||
label: `Python: ${pyCmd} -m headroom.cli`,
|
||||
command: pyCmd,
|
||||
args: ["-m", "headroom.cli", ...commonArgs],
|
||||
checkCommand: pyCmd,
|
||||
checkArgs: ["-c", HEADROOM_MODULE_DISCOVERY_SNIPPET],
|
||||
});
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
private getConfiguredPythonCommand(): string | null {
|
||||
const configured = typeof this.config.pythonPath === "string"
|
||||
? this.config.pythonPath.trim()
|
||||
: "";
|
||||
return configured.length > 0 ? configured : null;
|
||||
}
|
||||
|
||||
private getPyenvResolvedHeadroom(): string | null {
|
||||
if (process.platform !== "win32") return null;
|
||||
|
||||
try {
|
||||
const result = spawnSync("pyenv", ["which", "headroom"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const resolved = (result.stdout ?? "").trim().split(/\r?\n/, 1)[0];
|
||||
if (!resolved || !existsSync(resolved)) return null;
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getPythonCommands(): string[] {
|
||||
const commands: string[] = [];
|
||||
const configured = this.getConfiguredPythonCommand() ?? "";
|
||||
if (configured.length > 0) {
|
||||
commands.push(configured);
|
||||
}
|
||||
for (const fallback of ["python", "python3", "py"]) {
|
||||
if (!commands.includes(fallback)) commands.push(fallback);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
private canExecute(command: string, args: string[], useShell = false): boolean {
|
||||
try {
|
||||
const result = spawnSync(command, args, {
|
||||
shell: useShell,
|
||||
stdio: "ignore",
|
||||
timeout: 5000,
|
||||
});
|
||||
if (result.error) return false;
|
||||
return result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getNpmGlobalPrefix(): string | null {
|
||||
try {
|
||||
const result = spawnSync("npm", ["prefix", "-g"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5000,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const prefix = (result.stdout ?? "").trim();
|
||||
return prefix.length > 0 ? prefix : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a URL, returning the parsed object or throwing a descriptive error. */
|
||||
function parseProxyUrl(proxyUrl: string): URL {
|
||||
try {
|
||||
return new URL(proxyUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
|
||||
const parsed = parseProxyUrl(proxyUrl);
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("proxyUrl must use http:// or https://");
|
||||
}
|
||||
|
||||
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
||||
throw new Error("proxyUrl must not include a path, query, or hash");
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
/** Returns true if the URL points to a local address (localhost or 127.0.0.1). */
|
||||
export function isLocalProxyUrl(proxyUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(proxyUrl);
|
||||
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function withDefaultPort(proxyUrl: string, defaultPort: number): string {
|
||||
const parsed = parseProxyUrl(proxyUrl);
|
||||
if (!parsed.port) {
|
||||
parsed.port = String(defaultPort);
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a configured URL and verify whether it is a running Headroom proxy.
|
||||
*/
|
||||
export async function probeHeadroomProxy(proxyUrl: string): Promise<ProxyProbeResult> {
|
||||
const origin = normalizeAndValidateProxyUrl(proxyUrl);
|
||||
const probeEndpoint = async (
|
||||
path: string,
|
||||
options: { readBody?: boolean } = {},
|
||||
): Promise<{ reachable: boolean; ok: boolean; status?: number; body?: string }> => {
|
||||
try {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
const body =
|
||||
response.ok && options.readBody
|
||||
? await response.text().catch(() => undefined)
|
||||
: undefined;
|
||||
return { reachable: true, ok: response.ok, status: response.status, body };
|
||||
} catch {
|
||||
return { reachable: false, ok: false };
|
||||
}
|
||||
};
|
||||
|
||||
const ready = await probeEndpoint("/readyz");
|
||||
const retrieveStats = await probeEndpoint("/v1/retrieve/stats", { readBody: true });
|
||||
if (retrieveStats.ok && hasHeadroomStatsShape(retrieveStats.body)) {
|
||||
return { reachable: true, isHeadroom: true };
|
||||
}
|
||||
|
||||
const stats = await probeEndpoint("/stats", { readBody: true });
|
||||
if (stats.ok && hasHeadroomStatsShape(stats.body)) {
|
||||
return { reachable: true, isHeadroom: true };
|
||||
}
|
||||
|
||||
const health = await probeEndpoint("/health");
|
||||
const anyReachable = ready.reachable || retrieveStats.reachable || stats.reachable || health.reachable;
|
||||
if (!anyReachable) {
|
||||
return { reachable: false, isHeadroom: false, reason: "proxy probe failed" };
|
||||
}
|
||||
|
||||
const reasons = [
|
||||
ready.reachable ? `readyz HTTP ${ready.status}` : "readyz unavailable",
|
||||
retrieveStats.reachable
|
||||
? `retrieve stats HTTP ${retrieveStats.status}`
|
||||
: "retrieve stats endpoint unavailable",
|
||||
stats.reachable ? `stats HTTP ${stats.status}` : "stats endpoint unavailable",
|
||||
health.reachable ? `health HTTP ${health.status}` : "health check failed",
|
||||
];
|
||||
return { reachable: true, isHeadroom: false, reason: reasons.join("; ") };
|
||||
}
|
||||
|
||||
function hasHeadroomStatsShape(body: string | undefined): boolean {
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
return (
|
||||
parsed !== null &&
|
||||
typeof parsed === "object" &&
|
||||
(Object.hasOwn(parsed, "proxy_inbound") ||
|
||||
Object.hasOwn(parsed, "api_requests") ||
|
||||
Object.hasOwn(parsed, "provider_tokens") ||
|
||||
Object.hasOwn(parsed, "proxy_compression_saved") ||
|
||||
Object.hasOwn(parsed, "store") ||
|
||||
Object.hasOwn(parsed, "recent_retrievals"))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHeadroomProxy(proxyUrl: string, timeoutMs: number): Promise<ProxyProbeResult> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const result = await probeHeadroomProxy(proxyUrl);
|
||||
if (result.reachable && result.isHeadroom) {
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return probeHeadroomProxy(proxyUrl);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* CCR (Compress-Cache-Retrieve) tool for OpenClaw.
|
||||
*
|
||||
* Allows the agent to retrieve original uncompressed content
|
||||
* from the Headroom proxy's compression store.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
|
||||
|
||||
export interface RetrieveToolConfig {
|
||||
proxyUrl: string;
|
||||
}
|
||||
|
||||
export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
|
||||
const proxyOrigin = normalizeAndValidateProxyUrl(config.proxyUrl);
|
||||
|
||||
return {
|
||||
name: "headroom_retrieve",
|
||||
description:
|
||||
"Retrieve original uncompressed content from Headroom's compression store. " +
|
||||
"Use when compressed context mentions a hash and you need the full details. " +
|
||||
"Pass the hash from the compression marker (24 hex characters). " +
|
||||
"Retrieval is by hash and always returns the full original content.",
|
||||
parameters: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
hash: {
|
||||
type: "string",
|
||||
description: "The 24-character hex hash from the compression marker",
|
||||
},
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
execute: async (args: { hash: string }): Promise<string> => {
|
||||
const { hash } = args;
|
||||
|
||||
// Validate hash format
|
||||
if (!/^[a-f0-9]{24}$/i.test(hash)) {
|
||||
return JSON.stringify({
|
||||
error: "Invalid hash format. Expected 24 hex characters.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${proxyOrigin}/v1/retrieve/${hash}`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => "");
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: HTTP ${resp.status}`,
|
||||
details: body,
|
||||
});
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
return typeof data === "string" ? data : JSON.stringify(data);
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
error: `Retrieval failed: ${error}`,
|
||||
hint: "The compressed content may have expired (default TTL: 5 minutes)",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user