mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Harden openclaw plugin proxy handling and metadata
This commit is contained in:
parent
c33f74d39d
commit
205ec54df2
12 changed files with 195 additions and 279 deletions
2
plugins/openclaw/.npmignore
Normal file
2
plugins/openclaw/.npmignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
.env
|
||||
|
|
@ -6,7 +6,7 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw).
|
|||
|
||||
```bash
|
||||
pip install "headroom-ai[proxy]"
|
||||
openclaw plugins install @headroom-ai/openclaw
|
||||
openclaw plugins install headroom-ai/openclaw
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
|
@ -14,6 +14,14 @@ openclaw plugins install @headroom-ai/openclaw
|
|||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"headroom": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"proxyUrl": "http://127.0.0.1:8787"
|
||||
}
|
||||
}
|
||||
},
|
||||
"slots": {
|
||||
"contextEngine": "headroom"
|
||||
}
|
||||
|
|
@ -21,7 +29,25 @@ openclaw plugins install @headroom-ai/openclaw
|
|||
}
|
||||
```
|
||||
|
||||
That's it. The plugin auto-starts the Headroom proxy if it's not already running.
|
||||
`proxyUrl` is required and must be localhost (`127.0.0.1` or `localhost`). The plugin never starts processes and only connects to the configured local proxy.
|
||||
|
||||
## Required Proxy Setup
|
||||
|
||||
Run Headroom proxy yourself before launching OpenClaw.
|
||||
|
||||
Python install:
|
||||
|
||||
```bash
|
||||
pip install "headroom-ai[proxy]"
|
||||
headroom proxy --host 127.0.0.1 --port 8787
|
||||
```
|
||||
|
||||
NPM install:
|
||||
|
||||
```bash
|
||||
npm install -g headroom-ai
|
||||
headroom proxy --host 127.0.0.1 --port 8787
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
@ -38,10 +64,7 @@ Compression is lossless via CCR (Compress-Cache-Retrieve): originals are stored
|
|||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `proxyUrl` | auto-detected | URL of the Headroom proxy |
|
||||
| `autoStart` | `true` | Start proxy automatically if not running |
|
||||
| `pythonPath` | auto-detected | Path to Python binary |
|
||||
| `proxyPort` | `8787` | Port for auto-started proxy |
|
||||
| `proxyUrl` | required | URL of an already running Headroom proxy (`http://127.0.0.1:<port>` or `http://localhost:<port>`) |
|
||||
|
||||
## Comparison with lossless-claw
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,7 @@
|
|||
"uiHints": {
|
||||
"proxyUrl": {
|
||||
"label": "Proxy URL",
|
||||
"help": "URL of the Headroom proxy (auto-detected or auto-started if not set)"
|
||||
},
|
||||
"pythonPath": {
|
||||
"label": "Python Path",
|
||||
"help": "Path to Python binary (auto-detected if not set)"
|
||||
},
|
||||
"autoStart": {
|
||||
"label": "Auto-Start Proxy",
|
||||
"help": "Automatically start the Headroom proxy if not already running"
|
||||
"help": "Required. URL of an already running Headroom proxy on localhost (example: http://127.0.0.1:8787)"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
|
|
@ -22,20 +14,17 @@
|
|||
"type": "boolean"
|
||||
},
|
||||
"proxyUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"pythonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"autoStart": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"proxyPort": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 65535
|
||||
"type": "string",
|
||||
"pattern": "^http:\\/\\/(localhost|127\\.0\\.0\\.1)(:\\d+)?$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"network": {
|
||||
"allow": [
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
plugins/openclaw/package-lock.json
generated
7
plugins/openclaw/package-lock.json
generated
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "@headroom-ai/openclaw",
|
||||
"name": "headroom-openclaw",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@headroom-ai/openclaw",
|
||||
"name": "headroom-openclaw",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -16,6 +16,9 @@
|
|||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": "*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -33,9 +33,23 @@
|
|||
"vitest": "^2.0.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"hooks": {
|
||||
"contextEngine": "dist/index.js",
|
||||
"tools": [
|
||||
"dist/index.js"
|
||||
]
|
||||
},
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
],
|
||||
"capabilities": {
|
||||
"network": {
|
||||
"allow": [
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export class HeadroomContextEngine {
|
|||
baseUrl: this.proxyUrl,
|
||||
fallback: true,
|
||||
tokenBudget: params.tokenBudget,
|
||||
});
|
||||
} as any);
|
||||
|
||||
if (!result.compressed || result.tokensSaved === 0) {
|
||||
return { messages: params.messages, estimatedTokens: result.tokensBefore };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export { default } from "./plugin/index.js";
|
||||
export { HeadroomContextEngine } from "./engine.js";
|
||||
export { ProxyManager } from "./proxy-manager.js";
|
||||
export { ProxyManager, normalizeAndValidateProxyUrl, probeHeadroomProxy } from "./proxy-manager.js";
|
||||
export { agentToOpenAI, openAIToAgent } from "./convert.js";
|
||||
export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Headroom OpenClaw Plugin — register ContextEngine + CCR retrieval tool.
|
||||
*
|
||||
* Usage:
|
||||
* openclaw plugins install @headroom-ai/openclaw
|
||||
* openclaw plugins install headroom-ai/openclaw
|
||||
*
|
||||
* Configuration (in ~/.openclaw/config.json or ~/.clawdbot/clawdbot.json):
|
||||
* {
|
||||
|
|
@ -16,13 +16,21 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { HeadroomContextEngine } from "../engine.js";
|
||||
import { normalizeAndValidateProxyUrl } from "../proxy-manager.js";
|
||||
import { createHeadroomRetrieveTool } from "../tools/headroom-retrieve.js";
|
||||
|
||||
export default function headroomPlugin(api: any) {
|
||||
const config = api.config?.plugins?.entries?.headroom?.config ?? {};
|
||||
const logger = api.logger ?? console;
|
||||
const rawProxyUrl = config.proxyUrl;
|
||||
if (!rawProxyUrl || typeof rawProxyUrl !== "string") {
|
||||
throw new Error(
|
||||
'[headroom] Missing required config: plugins.entries.headroom.config.proxyUrl (example: "http://127.0.0.1:8787")',
|
||||
);
|
||||
}
|
||||
const proxyUrl = normalizeAndValidateProxyUrl(rawProxyUrl);
|
||||
|
||||
const engine = new HeadroomContextEngine(config, {
|
||||
const engine = new HeadroomContextEngine({ ...config, proxyUrl }, {
|
||||
info: (m: string) => logger.info(m),
|
||||
warn: (m: string) => logger.warn(m),
|
||||
error: (m: string) => logger.error(m),
|
||||
|
|
@ -34,9 +42,8 @@ export default function headroomPlugin(api: any) {
|
|||
|
||||
// Register CCR retrieval tool (active once proxy is running)
|
||||
api.registerTool((ctx: any) => {
|
||||
const proxyUrl = engine.getProxyUrl();
|
||||
if (!proxyUrl) return null;
|
||||
return createHeadroomRetrieveTool({ proxyUrl });
|
||||
const activeProxyUrl = engine.getProxyUrl() ?? proxyUrl;
|
||||
return createHeadroomRetrieveTool({ proxyUrl: activeProxyUrl });
|
||||
});
|
||||
|
||||
logger.info("[headroom] Plugin registered");
|
||||
|
|
|
|||
|
|
@ -1,27 +1,14 @@
|
|||
/**
|
||||
* Manages the Headroom proxy process lifecycle.
|
||||
* Manages connectivity to an externally managed Headroom proxy.
|
||||
*
|
||||
* - Detects if a proxy is already running (e.g., user has `headroom proxy` for Claude Code)
|
||||
* - If not, spawns one as a child process with auto-assigned port
|
||||
* - Health checks, restart on crash, graceful shutdown
|
||||
* Security model:
|
||||
* - No process execution
|
||||
* - No environment variable access
|
||||
* - Localhost-only network access (127.0.0.1 / localhost)
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const DEFAULT_PORT = 8787;
|
||||
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
||||
const STARTUP_TIMEOUT_MS = 15_000;
|
||||
const RESTART_DELAY_MS = 2_000;
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
|
||||
export interface ProxyManagerConfig {
|
||||
proxyUrl?: string;
|
||||
pythonPath?: string;
|
||||
autoStart?: boolean;
|
||||
proxyPort?: number;
|
||||
}
|
||||
|
||||
export interface ProxyManagerLogger {
|
||||
|
|
@ -31,6 +18,12 @@ export interface ProxyManagerLogger {
|
|||
debug(message: string): void;
|
||||
}
|
||||
|
||||
export interface ProxyProbeResult {
|
||||
reachable: boolean;
|
||||
isHeadroom: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const defaultLogger: ProxyManagerLogger = {
|
||||
info: (m) => console.log(`[headroom] ${m}`),
|
||||
warn: (m) => console.warn(`[headroom] ${m}`),
|
||||
|
|
@ -41,12 +34,7 @@ const defaultLogger: ProxyManagerLogger = {
|
|||
export class ProxyManager {
|
||||
private config: ProxyManagerConfig;
|
||||
private logger: ProxyManagerLogger;
|
||||
private process: ChildProcess | null = null;
|
||||
private proxyUrl: string | null = null;
|
||||
private weStartedIt = false;
|
||||
private restartCount = 0;
|
||||
private healthInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(config: ProxyManagerConfig = {}, logger?: ProxyManagerLogger) {
|
||||
this.config = config;
|
||||
|
|
@ -54,149 +42,39 @@ export class ProxyManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Ensure a proxy is available. Returns the URL.
|
||||
*
|
||||
* 1. If proxyUrl is configured, use it
|
||||
* 2. Check if proxy is already running on default port
|
||||
* 3. If autoStart, spawn one
|
||||
* Ensure a proxy is available. Returns the normalized URL origin.
|
||||
*/
|
||||
async start(): Promise<string> {
|
||||
// Option 1: Explicit URL configured
|
||||
if (this.config.proxyUrl) {
|
||||
const url = this.config.proxyUrl.replace(/\/+$/, "");
|
||||
if (await this.healthCheck(url)) {
|
||||
this.proxyUrl = url;
|
||||
this.logger.info(`Connected to proxy at ${url}`);
|
||||
return url;
|
||||
}
|
||||
throw new Error(`Headroom proxy not reachable at ${url}`);
|
||||
if (!this.config.proxyUrl) {
|
||||
throw new Error(
|
||||
"Headroom proxy URL is required. Configure plugins.entries.headroom.config.proxyUrl " +
|
||||
'(example: "http://127.0.0.1:8787").',
|
||||
);
|
||||
}
|
||||
|
||||
// Option 2: Check default port
|
||||
const defaultUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
|
||||
if (await this.healthCheck(defaultUrl)) {
|
||||
this.proxyUrl = defaultUrl;
|
||||
this.logger.info(`Found running proxy at ${defaultUrl}`);
|
||||
this.startHealthMonitor();
|
||||
return defaultUrl;
|
||||
const url = normalizeAndValidateProxyUrl(this.config.proxyUrl);
|
||||
const probe = await probeHeadroomProxy(url);
|
||||
|
||||
if (probe.reachable && probe.isHeadroom) {
|
||||
this.proxyUrl = url;
|
||||
this.logger.info(`Headroom proxy already running at ${url}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
// Option 3: Auto-start
|
||||
if (this.config.autoStart !== false) {
|
||||
return this.spawnProxy();
|
||||
if (probe.reachable && !probe.isHeadroom) {
|
||||
throw new Error(
|
||||
`Service reachable at ${url}, but it does not appear to be a Headroom proxy (${probe.reason ?? "unknown service"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Headroom proxy not running. Start with: headroom proxy --port 8787\n" +
|
||||
"Or install: pip install 'headroom-ai[proxy]'",
|
||||
);
|
||||
throw new Error(`Headroom proxy not reachable at ${url}. Ensure the proxy is running first.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the headroom proxy as a child process.
|
||||
*/
|
||||
private async spawnProxy(): Promise<string> {
|
||||
const pythonPath = await this.findPython();
|
||||
if (!pythonPath) {
|
||||
throw new Error(
|
||||
"Python not found. Install Python 3.10+ and run: pip install 'headroom-ai[proxy]'",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if headroom-ai is installed
|
||||
const installed = await this.checkHeadroomInstalled(pythonPath);
|
||||
if (!installed) {
|
||||
throw new Error(
|
||||
"headroom-ai Python package not found.\n" +
|
||||
"Install with: pip install 'headroom-ai[proxy]'",
|
||||
);
|
||||
}
|
||||
|
||||
const port = this.config.proxyPort ?? 0; // 0 = OS picks a free port
|
||||
const actualPort = port === 0 ? await this.findFreePort() : port;
|
||||
const url = `http://127.0.0.1:${actualPort}`;
|
||||
|
||||
this.logger.info(`Starting proxy on port ${actualPort}...`);
|
||||
|
||||
// Log file
|
||||
const logDir = join(homedir(), ".headroom", "logs");
|
||||
const logPath = join(logDir, "openclaw-proxy.log");
|
||||
|
||||
let logStream: ReturnType<typeof createWriteStream> | null = null;
|
||||
try {
|
||||
const { mkdirSync } = await import("node:fs");
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
logStream = createWriteStream(logPath, { flags: "a" });
|
||||
} catch {
|
||||
// Can't create log file — use /dev/null
|
||||
}
|
||||
|
||||
const proc = spawn(
|
||||
pythonPath,
|
||||
["-m", "headroom.cli", "proxy", "--port", String(actualPort)],
|
||||
{
|
||||
env: { ...process.env, PYTHONIOENCODING: "utf-8" },
|
||||
stdio: ["ignore", logStream ? "pipe" : "ignore", logStream ? "pipe" : "ignore"],
|
||||
detached: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (logStream) {
|
||||
proc.stdout?.pipe(logStream);
|
||||
proc.stderr?.pipe(logStream);
|
||||
}
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
if (!this.disposed && this.weStartedIt) {
|
||||
this.logger.warn(`Proxy exited with code ${code}`);
|
||||
this.handleCrash();
|
||||
}
|
||||
});
|
||||
|
||||
this.process = proc;
|
||||
this.weStartedIt = true;
|
||||
|
||||
// Wait for healthy
|
||||
const healthy = await this.waitForHealthy(url, STARTUP_TIMEOUT_MS);
|
||||
if (!healthy) {
|
||||
proc.kill();
|
||||
this.process = null;
|
||||
throw new Error(
|
||||
`Proxy failed to start within ${STARTUP_TIMEOUT_MS / 1000}s. Check ${logPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.proxyUrl = url;
|
||||
this.logger.info(`Proxy started on port ${actualPort} (PID: ${proc.pid})`);
|
||||
this.startHealthMonitor();
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the proxy if we started it.
|
||||
* No-op: plugin never starts or manages external processes.
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.disposed = true;
|
||||
if (this.healthInterval) {
|
||||
clearInterval(this.healthInterval);
|
||||
this.healthInterval = null;
|
||||
}
|
||||
if (this.process && this.weStartedIt) {
|
||||
this.logger.info("Stopping proxy...");
|
||||
this.process.kill("SIGTERM");
|
||||
// Give it 3s to shutdown gracefully
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.process?.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 3000);
|
||||
this.process?.on("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.process = null;
|
||||
}
|
||||
this.proxyUrl = null;
|
||||
}
|
||||
|
||||
getUrl(): string | null {
|
||||
|
|
@ -204,94 +82,64 @@ export class ProxyManager {
|
|||
}
|
||||
|
||||
// --- Internal ---
|
||||
}
|
||||
|
||||
private async healthCheck(url: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${url}/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
return resp.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
export function normalizeAndValidateProxyUrl(proxyUrl: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(proxyUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid proxyUrl: "${proxyUrl}"`);
|
||||
}
|
||||
|
||||
private async waitForHealthy(url: string, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.healthCheck(url)) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
if (parsed.protocol !== "http:") {
|
||||
throw new Error("proxyUrl must use http://");
|
||||
}
|
||||
if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost") {
|
||||
throw new Error("proxyUrl host must be localhost or 127.0.0.1");
|
||||
}
|
||||
|
||||
private startHealthMonitor(): void {
|
||||
if (this.healthInterval) return;
|
||||
this.healthInterval = setInterval(async () => {
|
||||
if (this.proxyUrl && !(await this.healthCheck(this.proxyUrl))) {
|
||||
this.logger.warn("Proxy health check failed");
|
||||
if (this.weStartedIt) this.handleCrash();
|
||||
}
|
||||
}, HEALTH_CHECK_INTERVAL_MS);
|
||||
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
||||
throw new Error("proxyUrl must not include a path, query, or hash");
|
||||
}
|
||||
|
||||
private async handleCrash(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
if (this.restartCount >= MAX_RESTART_ATTEMPTS) {
|
||||
this.logger.error(`Proxy crashed ${MAX_RESTART_ATTEMPTS} times. Giving up.`);
|
||||
return;
|
||||
}
|
||||
this.restartCount++;
|
||||
this.logger.info(`Restarting proxy (attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS})...`);
|
||||
await new Promise((r) => setTimeout(r, RESTART_DELAY_MS));
|
||||
try {
|
||||
await this.spawnProxy();
|
||||
} catch (e) {
|
||||
this.logger.error(`Restart failed: ${e}`);
|
||||
}
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
private async findPython(): Promise<string | null> {
|
||||
if (this.config.pythonPath) return this.config.pythonPath;
|
||||
/**
|
||||
* 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);
|
||||
|
||||
for (const cmd of ["python3", "python"]) {
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
const version = execSync(`${cmd} --version 2>&1`, { encoding: "utf-8" }).trim();
|
||||
if (version.includes("Python 3.")) return cmd;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async checkHeadroomInstalled(pythonPath: string): Promise<boolean> {
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync(`${pythonPath} -c "import headroom"`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async findFreePort(): Promise<number> {
|
||||
const { createServer } = await import("node:net");
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === "object") {
|
||||
const port = addr.port;
|
||||
server.close(() => resolve(port));
|
||||
} else {
|
||||
reject(new Error("Could not find free port"));
|
||||
}
|
||||
});
|
||||
try {
|
||||
const health = await fetch(`${origin}/health`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
if (!health.ok) {
|
||||
return { reachable: false, isHeadroom: false, reason: `health HTTP ${health.status}` };
|
||||
}
|
||||
} catch {
|
||||
return { reachable: false, isHeadroom: false, reason: "health check failed" };
|
||||
}
|
||||
|
||||
try {
|
||||
const retrieveStats = await fetch(`${origin}/v1/retrieve/stats`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
if (retrieveStats.ok) {
|
||||
return { reachable: true, isHeadroom: true };
|
||||
}
|
||||
return {
|
||||
reachable: true,
|
||||
isHeadroom: false,
|
||||
reason: `retrieve stats HTTP ${retrieveStats.status}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
reachable: true,
|
||||
isHeadroom: false,
|
||||
reason: "retrieve stats endpoint unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
*/
|
||||
|
||||
/* 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:
|
||||
|
|
@ -45,8 +48,8 @@ export function createHeadroomRetrieveTool(config: RetrieveToolConfig) {
|
|||
|
||||
try {
|
||||
const url = query
|
||||
? `${config.proxyUrl}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
|
||||
: `${config.proxyUrl}/v1/retrieve/${hash}`;
|
||||
? `${proxyOrigin}/v1/retrieve/${hash}?query=${encodeURIComponent(query)}`
|
||||
: `${proxyOrigin}/v1/retrieve/${hash}`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
|
|
|
|||
|
|
@ -7,12 +7,39 @@
|
|||
* Requires: Python 3 + headroom-ai[proxy] installed
|
||||
* Run: HEADROOM_INTEGRATION=1 npx vitest run test/engine.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi, afterEach } from "vitest";
|
||||
import { HeadroomContextEngine } from "../src/engine.js";
|
||||
import { agentToOpenAI, openAIToAgent } from "../src/convert.js";
|
||||
import { ProxyManager } from "../src/proxy-manager.js";
|
||||
import { ProxyManager, probeHeadroomProxy } from "../src/proxy-manager.js";
|
||||
|
||||
const RUN = process.env.HEADROOM_INTEGRATION === "1";
|
||||
const PROXY_URL = process.env.HEADROOM_PROXY_URL ?? "http://127.0.0.1:8787";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Proxy probing", () => {
|
||||
it("detects running Headroom proxy", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await probeHeadroomProxy("http://127.0.0.1:8787");
|
||||
expect(result).toEqual({ reachable: true, isHeadroom: true });
|
||||
});
|
||||
|
||||
it("flags non-headroom service at configured URL", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, status: 200 })
|
||||
.mockResolvedValueOnce({ ok: false, status: 404 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const manager = new ProxyManager({ proxyUrl: "http://127.0.0.1:8787" });
|
||||
await expect(manager.start()).rejects.toThrow(/does not appear to be a Headroom proxy/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentMessage conversion", () => {
|
||||
it("converts user message", () => {
|
||||
|
|
@ -119,11 +146,11 @@ describe("AgentMessage conversion", () => {
|
|||
});
|
||||
|
||||
describe.skipIf(!RUN)("ProxyManager", () => {
|
||||
it("detects running proxy or starts one", { timeout: 30000 }, async () => {
|
||||
const manager = new ProxyManager({ autoStart: true });
|
||||
it("connects to configured proxy URL", { timeout: 30000 }, async () => {
|
||||
const manager = new ProxyManager({ proxyUrl: PROXY_URL });
|
||||
try {
|
||||
const url = await manager.start();
|
||||
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(url).toMatch(/^http:\/\/(127\.0\.0\.1|localhost):\d+$/);
|
||||
|
||||
// Verify health
|
||||
const resp = await fetch(`${url}/health`);
|
||||
|
|
@ -138,7 +165,7 @@ describe.skipIf(!RUN)("HeadroomContextEngine", () => {
|
|||
let engine: HeadroomContextEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new HeadroomContextEngine({ autoStart: true });
|
||||
engine = new HeadroomContextEngine({ proxyUrl: PROXY_URL });
|
||||
await engine.bootstrap({
|
||||
sessionId: "test-session",
|
||||
sessionFile: "/tmp/test-session.jsonl",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue