Commit 5c713cfb authored by edy's avatar edy

fix(desktop): support chunked workspace agent bundles

parent a6063e5b
......@@ -16,10 +16,10 @@ interface RunnerInput {
}
interface AgentCommandModule {
t?: (options: Record<string, unknown>, runtime?: Record<string, unknown>) => Promise<unknown>;
[exportName: string]: unknown;
}
interface InstrumentedModelSelectionModule {
interface InstrumentedAgentEventModule {
__qjcOnAgentEvent?: (listener: (event: InstrumentedAgentEvent) => void) => (() => boolean) | (() => void);
}
......@@ -66,11 +66,26 @@ async function resolveAgentModulePath(vendorPackageDir: string): Promise<string>
return path.join(distDir, agentModuleFile);
}
function buildInstrumentationKey(agentModulePath: string, modelSelectionSpecifier: string): string {
interface LocalModuleImport {
specifier: string;
resolvedPath: string;
}
interface LocalModuleGraphNode {
filePath: string;
source: string;
imports: LocalModuleImport[];
}
type AgentCommandFunction = (options: Record<string, unknown>, runtime?: Record<string, unknown>) => Promise<unknown>;
function buildInstrumentationKey(agentModulePath: string, eventModulePath: string, instrumentedSourcePaths: string[]): string {
return createHash("sha1")
.update(agentModulePath)
.update("\n")
.update(modelSelectionSpecifier)
.update(eventModulePath)
.update("\n")
.update(instrumentedSourcePaths.join("\n"))
.digest("hex")
.slice(0, 12);
}
......@@ -106,29 +121,211 @@ function rewriteModuleSpecifiers(
};
return source
.replace(/(^|[\r\n])(\s*(?:import|export)\s+[^"'\r\n;]+?\s+from\s*)(["'])([^"'\r\n]+)\3/g, (
.replace(/\b((?:import|export)\s+[^"'\r\n;]+?\s+from\s*)(["'])([^"'\r\n]+)\2/g, (
_match,
linePrefix: string,
prefix: string,
quote: string,
specifier: string
) => {
return `${linePrefix}${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
return `${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
})
.replace(/(^|[\r\n])(\s*import\s*)(["'])([^"'\r\n]+)\3/g, (
.replace(/\b(import\s*)(["'])([^"'\r\n]+)\2/g, (
_match,
linePrefix: string,
prefix: string,
quote: string,
specifier: string
) => {
return `${linePrefix}${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
return `${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
})
.replace(/\b(import\s*\(\s*)(["'])([^"'\r\n]+)\2/g, (_match, prefix: string, quote: string, specifier: string) => {
return `${prefix}${quote}${toModuleUrl(specifier)}${quote}`;
});
}
function normalizePathForCompare(value: string): string {
return path.resolve(value).toLowerCase();
}
function isPathInsideDirectory(filePath: string, directoryPath: string): boolean {
const relativePath = path.relative(directoryPath, filePath);
return Boolean(relativePath) && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
}
function resolveLocalJavaScriptSpecifier(baseDir: string, specifier: string, distDir: string): string | null {
if (!specifier.startsWith(".") || !specifier.endsWith(".js")) {
return null;
}
const resolvedPath = path.resolve(baseDir, specifier);
if (resolvedPath !== distDir && !isPathInsideDirectory(resolvedPath, distDir)) {
return null;
}
return resolvedPath;
}
function extractLocalModuleImports(source: string, baseDir: string, distDir: string): LocalModuleImport[] {
const imports: LocalModuleImport[] = [];
const seen = new Set<string>();
const addSpecifier = (specifier: string) => {
const resolvedPath = resolveLocalJavaScriptSpecifier(baseDir, specifier, distDir);
if (!resolvedPath) {
return;
}
const key = `${specifier}\n${normalizePathForCompare(resolvedPath)}`;
if (seen.has(key)) {
return;
}
seen.add(key);
imports.push({ specifier, resolvedPath });
};
const fromImportPattern = /\b(?:import|export)\s+[^"'\r\n;]+?\s+from\s*(["'])([^"'\r\n]+)\1/g;
for (const match of source.matchAll(fromImportPattern)) {
addSpecifier(match[2]);
}
const sideEffectImportPattern = /\bimport\s*(["'])([^"'\r\n]+)\1/g;
for (const match of source.matchAll(sideEffectImportPattern)) {
addSpecifier(match[2]);
}
const dynamicImportPattern = /\bimport\s*\(\s*(["'])([^"'\r\n]+)\1/g;
for (const match of source.matchAll(dynamicImportPattern)) {
addSpecifier(match[2]);
}
return imports;
}
async function buildLocalModuleGraph(agentModulePath: string): Promise<Map<string, LocalModuleGraphNode>> {
const distDir = path.dirname(agentModulePath);
const graph = new Map<string, LocalModuleGraphNode>();
const queue = [agentModulePath];
while (queue.length > 0) {
const filePath = queue.shift()!;
const key = normalizePathForCompare(filePath);
if (graph.has(key)) {
continue;
}
const source = await readFile(filePath, "utf8");
const imports = extractLocalModuleImports(source, path.dirname(filePath), distDir);
graph.set(key, {
filePath,
source,
imports
});
for (const importedModule of imports) {
const importedKey = normalizePathForCompare(importedModule.resolvedPath);
if (!graph.has(importedKey)) {
queue.push(importedModule.resolvedPath);
}
}
}
return graph;
}
function resolveOnAgentEventLocalBinding(source: string): string | null {
if (/\bexport\s+(?:async\s+)?function\s+onAgentEvent\b/.test(source)
|| /\bexport\s+(?:const|let|var)\s+onAgentEvent\b/.test(source)) {
return "onAgentEvent";
}
const identifierPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const exportBlockPattern = /\bexport\s*\{([^}]+)\}/g;
for (const match of source.matchAll(exportBlockPattern)) {
const afterExportBlock = source.slice((match.index ?? 0) + match[0].length).trimStart();
if (afterExportBlock.startsWith("from")) {
continue;
}
for (const rawSpecifier of match[1].split(",")) {
const specifier = rawSpecifier.trim();
const aliasMatch = specifier.match(/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/);
if (!aliasMatch) {
continue;
}
const localName = aliasMatch[1];
const exportedName = aliasMatch[2] ?? localName;
if ((localName === "onAgentEvent" || exportedName === "onAgentEvent") && identifierPattern.test(localName)) {
return localName;
}
}
}
return null;
}
function selectAgentEventModule(graph: Map<string, LocalModuleGraphNode>): LocalModuleGraphNode | null {
const candidates = [...graph.values()].filter((node) => resolveOnAgentEventLocalBinding(node.source));
if (candidates.length === 0) {
return null;
}
return candidates.sort((left, right) => {
const leftFile = path.basename(left.filePath);
const rightFile = path.basename(right.filePath);
const leftPriority = /^agent-events-[A-Za-z0-9_-]+\.js$/.test(leftFile) ? 0 : 1;
const rightPriority = /^agent-events-[A-Za-z0-9_-]+\.js$/.test(rightFile) ? 0 : 1;
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return leftFile.localeCompare(rightFile);
})[0];
}
function resolveModulePathFromGraph(
graph: Map<string, LocalModuleGraphNode>,
sourcePath: string,
targetPath: string
): string[] | null {
const sourceKey = normalizePathForCompare(sourcePath);
const targetKey = normalizePathForCompare(targetPath);
const queue = [sourceKey];
const parents = new Map<string, string | null>([[sourceKey, null]]);
while (queue.length > 0) {
const currentKey = queue.shift()!;
if (currentKey === targetKey) {
break;
}
const currentNode = graph.get(currentKey);
if (!currentNode) {
continue;
}
for (const importedModule of currentNode.imports) {
const importedKey = normalizePathForCompare(importedModule.resolvedPath);
if (parents.has(importedKey)) {
continue;
}
parents.set(importedKey, currentKey);
queue.push(importedKey);
}
}
if (!parents.has(targetKey)) {
return null;
}
const pathKeys: string[] = [];
let cursor: string | null = targetKey;
while (cursor) {
pathKeys.push(cursor);
cursor = parents.get(cursor) ?? null;
}
return pathKeys.reverse()
.map((key) => graph.get(key)?.filePath)
.filter((value): value is string => typeof value === "string");
}
async function ensureInstrumentationNodeModulesLink(instrumentationDir: string, vendorPackageDir: string): Promise<void> {
const vendorNodeModulesPath = path.join(vendorPackageDir, "node_modules");
try {
......@@ -161,43 +358,92 @@ async function ensureInstrumentationNodeModulesLink(instrumentationDir: string,
async function ensureInstrumentedWorkspaceModules(agentModulePath: string, instrumentationDir: string): Promise<{
agentModuleUrl: string;
modelSelectionModuleUrl: string;
eventModuleUrl: string;
}> {
const agentSource = await readFile(agentModulePath, "utf8");
const modelSelectionImportMatch = agentSource.match(/from\s+["'](\.\/model-selection-[^"']+\.js)["']/);
if (!modelSelectionImportMatch) {
throw new Error(`Unable to locate model-selection import in ${agentModulePath}.`);
const graph = await buildLocalModuleGraph(agentModulePath);
const eventModule = selectAgentEventModule(graph);
const onAgentEventLocalBinding = eventModule ? resolveOnAgentEventLocalBinding(eventModule.source) : null;
if (!eventModule || !onAgentEventLocalBinding) {
const moduleFiles = [...graph.values()].map((node) => path.basename(node.filePath)).sort();
throw new Error(`Unable to locate OpenClaw onAgentEvent export from ${agentModulePath}. Reachable modules: ${moduleFiles.join(", ") || "(none)"}.`);
}
const instrumentedSourcePaths = resolveModulePathFromGraph(graph, agentModulePath, eventModule.filePath);
if (!instrumentedSourcePaths?.length) {
throw new Error(`Unable to resolve import path from ${agentModulePath} to ${eventModule.filePath}.`);
}
const modelSelectionSpecifier = modelSelectionImportMatch[1];
const distDir = path.dirname(agentModulePath);
const vendorPackageDir = path.dirname(distDir);
const modelSelectionPath = path.resolve(distDir, modelSelectionSpecifier);
const instrumentationKey = buildInstrumentationKey(agentModulePath, modelSelectionSpecifier);
const instrumentedAgentFileName = `.qjc-agent-${instrumentationKey}.js`;
const instrumentedModelSelectionFileName = `.qjc-model-selection-${instrumentationKey}.js`;
const instrumentedAgentPath = path.join(instrumentationDir, instrumentedAgentFileName);
const instrumentedModelSelectionPath = path.join(instrumentationDir, instrumentedModelSelectionFileName);
const instrumentedModelSelectionUrl = pathToFileURL(instrumentedModelSelectionPath).href;
const instrumentationKey = buildInstrumentationKey(agentModulePath, eventModule.filePath, instrumentedSourcePaths);
const instrumentedPathBySourcePath = new Map<string, string>();
await mkdir(instrumentationDir, { recursive: true });
await writeFile(path.join(instrumentationDir, "package.json"), JSON.stringify({ type: "module" }, null, 2), "utf8");
await ensureInstrumentationNodeModulesLink(instrumentationDir, vendorPackageDir);
const modelSelectionSource = await readFile(modelSelectionPath, "utf8");
const instrumentedModelSelectionSource = `${rewriteModuleSpecifiers(modelSelectionSource, distDir)}\nexport { onAgentEvent as __qjcOnAgentEvent };\n`;
await writeFile(instrumentedModelSelectionPath, instrumentedModelSelectionSource, "utf8");
for (const sourcePath of instrumentedSourcePaths) {
const role = normalizePathForCompare(sourcePath) === normalizePathForCompare(agentModulePath)
? "agent"
: normalizePathForCompare(sourcePath) === normalizePathForCompare(eventModule.filePath)
? "agent-events"
: "agent-link";
const sourceStem = path.basename(sourcePath, ".js").replace(/[^A-Za-z0-9_-]/g, "-");
instrumentedPathBySourcePath.set(
normalizePathForCompare(sourcePath),
path.join(instrumentationDir, `.qjc-${role}-${sourceStem}-${instrumentationKey}.js`)
);
}
const instrumentedAgentSource = rewriteModuleSpecifiers(agentSource, distDir, {
[modelSelectionSpecifier]: instrumentedModelSelectionUrl
});
await writeFile(instrumentedAgentPath, instrumentedAgentSource, "utf8");
for (const sourcePath of [...instrumentedSourcePaths].reverse()) {
const sourceNode = graph.get(normalizePathForCompare(sourcePath));
const instrumentedPath = instrumentedPathBySourcePath.get(normalizePathForCompare(sourcePath));
if (!sourceNode || !instrumentedPath) {
continue;
}
const overrides = Object.fromEntries(
sourceNode.imports
.map((importedModule): [string, string] | null => {
const importedInstrumentedPath = instrumentedPathBySourcePath.get(normalizePathForCompare(importedModule.resolvedPath));
return importedInstrumentedPath
? [importedModule.specifier, pathToFileURL(importedInstrumentedPath).href]
: null;
})
.filter((entry): entry is [string, string] => Array.isArray(entry))
);
const instrumentedSource = rewriteModuleSpecifiers(sourceNode.source, path.dirname(sourcePath), overrides);
const finalSource = normalizePathForCompare(sourcePath) === normalizePathForCompare(eventModule.filePath)
? `${instrumentedSource}\nexport { ${onAgentEventLocalBinding} as __qjcOnAgentEvent };\n`
: instrumentedSource;
await writeFile(instrumentedPath, finalSource, "utf8");
}
const instrumentedAgentPath = instrumentedPathBySourcePath.get(normalizePathForCompare(agentModulePath));
const instrumentedEventModulePath = instrumentedPathBySourcePath.get(normalizePathForCompare(eventModule.filePath));
if (!instrumentedAgentPath || !instrumentedEventModulePath) {
throw new Error("Unable to prepare OpenClaw workspace instrumentation files.");
}
return {
agentModuleUrl: pathToFileURL(instrumentedAgentPath).href,
modelSelectionModuleUrl: pathToFileURL(instrumentedModelSelectionPath).href
eventModuleUrl: pathToFileURL(instrumentedEventModulePath).href
};
}
function resolveAgentCommand(agentModule: AgentCommandModule): AgentCommandFunction {
const preferredExportNames = ["t", "agentCommandFromIngress", "agentCommand", "r", "n"];
for (const exportName of preferredExportNames) {
const candidate = agentModule[exportName];
if (typeof candidate === "function") {
return candidate as AgentCommandFunction;
}
}
const actualExportNames = Object.keys(agentModule).sort();
throw new Error(`Bundled OpenClaw agent module does not expose a supported agent command export. Expected one of: ${preferredExportNames.join(", ")}. Actual exports: ${actualExportNames.join(", ") || "(none)"}.`);
}
function extractReplyText(result: unknown): string {
const payloads = Array.isArray((result as { payloads?: unknown[] } | null)?.payloads)
? ((result as { payloads: unknown[] }).payloads)
......@@ -280,14 +526,12 @@ async function main(): Promise<void> {
const agentModulePath = await resolveAgentModulePath(input.vendorPackageDir);
const instrumentedModules = await ensureInstrumentedWorkspaceModules(agentModulePath, resolveInstrumentationDir(input));
const agentModule = await import(instrumentedModules.agentModuleUrl) as AgentCommandModule;
const modelSelectionModule = await import(instrumentedModules.modelSelectionModuleUrl) as InstrumentedModelSelectionModule;
if (typeof agentModule.t !== "function") {
throw new Error("Bundled OpenClaw agent module does not expose agentCommand.");
}
const eventModule = await import(instrumentedModules.eventModuleUrl) as InstrumentedAgentEventModule;
const agentCommand = resolveAgentCommand(agentModule);
let streamedText = "";
const unsubscribe = typeof modelSelectionModule.__qjcOnAgentEvent === "function"
? modelSelectionModule.__qjcOnAgentEvent((event) => {
const unsubscribe = typeof eventModule.__qjcOnAgentEvent === "function"
? eventModule.__qjcOnAgentEvent((event) => {
if (event.runId !== runId || event.stream !== "assistant") {
return;
}
......@@ -327,7 +571,7 @@ async function main(): Promise<void> {
const runtimeSession = createRuntimeSessionIdentity(input.sessionId);
const message = `${input.prompt}${renderAttachmentPrelude(input.projectRoot, input.attachments)}`.trim();
try {
const result = await agentModule.t({
const result = await agentCommand({
message,
sessionId: runtimeSession.sessionId,
sessionKey: runtimeSession.sessionKey,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment