feat: assemble resource prompts and skills

This commit is contained in:
Codex
2026-06-02 20:40:14 +08:00
parent a53f5b8a0d
commit 3018b8a937
11 changed files with 394 additions and 15 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
import type { BackendEvent, BackendTurnResult, CommandRecord, RunRecord } from "../common/types.js";
import type { BackendEvent, BackendTurnResult, CommandRecord, InitialPromptAssembly, RunRecord } from "../common/types.js";
import { CodexStdioBackendSession, runCodexStdioTurn, type CodexStdioTurnOptions } from "./codex-stdio.js";
import { backendProfileSpec } from "../common/backend-profiles.js";
@@ -15,6 +15,7 @@ export interface BackendAdapterOptions {
codexHome?: string;
workspacePath?: string;
abortSignal?: AbortSignal;
initialPrompt?: InitialPromptAssembly;
onEvent?: (event: BackendEvent) => void | Promise<void>;
onActiveTurn?: (control: BackendActiveTurnControl) => void | (() => void);
env?: NodeJS.ProcessEnv;
@@ -59,6 +60,7 @@ export function backendTurnOptions(run: RunRecord, command: CommandRecord, optio
if (options.codexCommand) turnOptions.command = options.codexCommand;
if (options.codexArgs) turnOptions.args = options.codexArgs;
if (options.env) turnOptions.env = options.env;
if (options.initialPrompt) turnOptions.initialPrompt = options.initialPrompt;
if (options.codexHome) turnOptions.codexHome = options.codexHome;
if (options.abortSignal) turnOptions.abortSignal = options.abortSignal;
if (options.onEvent) turnOptions.onEvent = options.onEvent;
+32 -2
View File
@@ -4,7 +4,7 @@ import { accessSync, constants as fsConstants } from "node:fs";
import { chmod, copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import * as readline from "node:readline";
import type { BackendEvent, BackendProfile, BackendTurnResult, FailureKind, JsonRecord, JsonValue, TerminalStatus } from "../common/types.js";
import type { BackendEvent, BackendProfile, BackendTurnResult, FailureKind, InitialPromptAssembly, JsonRecord, JsonValue, TerminalStatus } from "../common/types.js";
import { redactJson, redactText } from "../common/redaction.js";
import { backendProfileSpec } from "../common/backend-profiles.js";
import { boundedTextSummary, commandOutputPayload } from "../common/output.js";
@@ -31,6 +31,8 @@ const childEnvSummaryKeys = [
"CODEX_API_KEY",
"GITHUB_TOKEN",
"GH_TOKEN",
"AGENTRUN_SKILLS_DIRS",
"HWLAB_CODE_AGENT_SKILLS_DIRS",
];
export interface CodexStdioTurnOptions {
@@ -46,6 +48,7 @@ export interface CodexStdioTurnOptions {
args?: string[];
env?: NodeJS.ProcessEnv;
codexHome?: string;
initialPrompt?: InitialPromptAssembly;
abortSignal?: AbortSignal;
onEvent?: (event: BackendEvent) => void | Promise<void>;
onActiveTurn?: (control: CodexActiveTurnControl) => void | (() => void);
@@ -445,6 +448,7 @@ async function runCodexStdioTurnWithSession(options: CodexStdioTurnOptions, sess
return nextThreadId;
};
const willResumeThread = Boolean(options.threadId);
if (options.threadId) {
try {
const threadResponse = requireResponseRecord(await client.request("thread/resume", withOptionalModel({ threadId: options.threadId, cwd: options.cwd, approvalPolicy: options.approvalPolicy, sandbox: options.sandbox }, options.model), requestTimeoutMs), "thread/resume");
@@ -458,7 +462,9 @@ async function runCodexStdioTurnWithSession(options: CodexStdioTurnOptions, sess
threadId = await startThread();
}
const turnResponse = requireResponseRecord(await client.request("turn/start", withOptionalModel({ threadId, input: textInput(options.prompt), cwd: options.cwd, approvalPolicy: options.approvalPolicy }, options.model), requestTimeoutMs), "turn/start");
const promptInjection = initialPromptInjection(options.initialPrompt, willResumeThread);
emitEvent({ type: "backend_status", payload: { phase: "initial-prompt-assembly", initialPromptInjected: promptInjection.injected, reason: promptInjection.reason, initialPrompt: options.initialPrompt?.summary ?? { available: false, valuesPrinted: false }, valuesPrinted: false } });
const turnResponse = requireResponseRecord(await client.request("turn/start", withOptionalModel({ threadId, input: textInputForUserMessage(options.prompt, promptInjection), cwd: options.cwd, approvalPolicy: options.approvalPolicy }, options.model), requestTimeoutMs), "turn/start");
turnId = requireNestedId(turnResponse, "turn/start", "turn");
emitEvent({ type: "backend_status", payload: { phase: "turn/start:completed", turnId } });
if (threadId && turnId && options.onActiveTurn) {
@@ -794,6 +800,30 @@ function textInput(text: string): JsonValue[] {
return [{ type: "text", text, text_elements: [] }];
}
function initialPromptInjection(initialPrompt: InitialPromptAssembly | undefined, resume: boolean): { text: string; injected: boolean; reason: string } {
if (!initialPrompt) return { text: "", injected: false, reason: "no-initial-prompt" };
if (resume) return { text: "", injected: false, reason: "thread-resume" };
return {
text: [
"<agentrun-initial-prompt>",
initialPrompt.text,
"</agentrun-initial-prompt>",
].join("\n"),
injected: true,
reason: "thread-start",
};
}
function textInputForUserMessage(prompt: string, initial: ReturnType<typeof initialPromptInjection>): JsonValue[] {
if (!initial.injected) return textInput(prompt);
return textInput([
initial.text,
"<agentrun-user-message>",
prompt,
"</agentrun-user-message>",
].join("\n"));
}
function agentMessageText(item: JsonRecord): string {
for (const key of ["text", "content", "message"]) {
const value = item[key];
+20
View File
@@ -6,6 +6,9 @@ export type FailureKind =
| "schema-invalid"
| "tenant-policy-denied"
| "secret-unavailable"
| "prompt-unavailable"
| "prompt-too-large"
| "skill-unavailable"
| "runner-lease-conflict"
| "backend-failed"
| "backend-protocol-error"
@@ -60,6 +63,18 @@ export interface ResourceBundleRef extends JsonRecord {
path: string;
kind: "node-script" | "bun-script" | "sh-script" | "executable";
}>;
promptRefs?: Array<{
name: string;
path: string;
inject?: "thread-start";
required?: boolean;
}>;
skillRefs?: Array<{
name: string;
path: string;
required?: boolean;
aggregateAs?: string;
}>;
submodules?: false;
lfs?: false;
credentialRef?: SecretRef;
@@ -283,6 +298,11 @@ export interface BackendTurnResult {
turnId?: string;
}
export interface InitialPromptAssembly extends JsonRecord {
text: string;
summary: JsonRecord;
}
export interface ApiErrorBody extends JsonRecord {
ok: false;
failureKind: FailureKind;
+49
View File
@@ -98,6 +98,8 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
result.sparsePaths = record.sparsePaths as string[];
}
if (record.toolAliases !== undefined) result.toolAliases = validateResourceToolAliases(record.toolAliases);
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
if (record.skillRefs !== undefined) result.skillRefs = validateResourceSkillRefs(record.skillRefs);
if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 });
if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 });
if (record.submodules === false) result.submodules = false;
@@ -124,6 +126,53 @@ function validateResourceToolAliases(value: unknown): NonNullable<ResourceBundle
});
}
function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleRef["promptRefs"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must be an array", { httpStatus: 400 });
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.promptRefs must contain at most 16 entries", { httpStatus: 400 });
const seen = new Set<string>();
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.promptRefs[${index}]`);
const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.promptRefs[${index}].name`);
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.promptRefs name ${name} is duplicated`, { httpStatus: 400 });
seen.add(name);
const promptPath = validateBundleRelativePath(requiredString(record, "path"), `resourceBundleRef.promptRefs[${index}].path`);
const inject = optionalString(record.inject) ?? "thread-start";
if (inject !== "thread-start") throw new AgentRunError("schema-invalid", `resourceBundleRef.promptRefs[${index}].inject must be thread-start in v0.1`, { httpStatus: 400 });
const required = record.required === undefined ? false : record.required;
if (typeof required !== "boolean") throw new AgentRunError("schema-invalid", `resourceBundleRef.promptRefs[${index}].required must be boolean`, { httpStatus: 400 });
return { name, path: promptPath, inject: "thread-start" as const, required };
});
}
function validateResourceSkillRefs(value: unknown): NonNullable<ResourceBundleRef["skillRefs"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.skillRefs must be an array", { httpStatus: 400 });
if (value.length > 32) throw new AgentRunError("schema-invalid", "resourceBundleRef.skillRefs must contain at most 32 entries", { httpStatus: 400 });
const seen = new Set<string>();
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.skillRefs[${index}]`);
const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.skillRefs[${index}].name`);
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs name ${name} is duplicated`, { httpStatus: 400 });
seen.add(name);
const skillPath = validateBundleRelativePath(requiredString(record, "path"), `resourceBundleRef.skillRefs[${index}].path`);
if (!skillPath.endsWith("SKILL.md")) throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs[${index}].path must point to SKILL.md in v0.1`, { httpStatus: 400 });
const required = record.required === undefined ? false : record.required;
if (typeof required !== "boolean") throw new AgentRunError("schema-invalid", `resourceBundleRef.skillRefs[${index}].required must be boolean`, { httpStatus: 400 });
const aggregateAs = optionalString(record.aggregateAs);
if (aggregateAs) validateResourceName(aggregateAs, `resourceBundleRef.skillRefs[${index}].aggregateAs`);
return { name, path: skillPath, required, ...(aggregateAs ? { aggregateAs } : {}) };
});
}
function validateResourceName(name: string, fieldName: string): string {
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 });
return name;
}
function validateBundleRelativePath(relativePath: string, fieldName: string): string {
if (relativePath.startsWith("/") || relativePath.includes("..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the checkout`, { httpStatus: 400 });
return relativePath;
}
export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy {
const timeout = record.timeoutMs;
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) throw new AgentRunError("schema-invalid", "executionPolicy.timeoutMs must be a positive number", { httpStatus: 400 });
+2
View File
@@ -174,6 +174,8 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
commitId: run.resourceBundleRef.commitId,
subdir: run.resourceBundleRef.subdir ?? null,
toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
skillRefs: run.resourceBundleRef.skillRefs ? { count: run.resourceBundleRef.skillRefs.length, names: run.resourceBundleRef.skillRefs.map((item) => item.name), required: run.resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
materialized: materialized as JsonValue,
};
}
+2
View File
@@ -472,6 +472,8 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
subdir: resourceBundleRef.subdir ?? null,
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
skillRefs: resourceBundleRef.skillRefs ? { count: resourceBundleRef.skillRefs.length, names: resourceBundleRef.skillRefs.map((item) => item.name), required: resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
submodules: resourceBundleRef.submodules ?? false,
lfs: resourceBundleRef.lfs ?? false,
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
+1 -1
View File
@@ -117,7 +117,7 @@ export function failureKindFromError(error: unknown): FailureKind {
export function terminalStatusForFailure(failureKind: FailureKind): TerminalStatus {
if (failureKind === "cancelled") return "cancelled";
if (failureKind === "secret-unavailable" || failureKind === "tenant-policy-denied" || failureKind === "schema-invalid") return "blocked";
if (failureKind === "secret-unavailable" || failureKind === "tenant-policy-denied" || failureKind === "schema-invalid" || failureKind === "prompt-unavailable" || failureKind === "prompt-too-large" || failureKind === "skill-unavailable") return "blocked";
return "failed";
}
+176 -2
View File
@@ -1,17 +1,45 @@
import { spawn } from "node:child_process";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { chmod, cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { AgentRunError } from "../common/errors.js";
import { redactText } from "../common/redaction.js";
import type { JsonRecord, ResourceBundleRef } from "../common/types.js";
import type { InitialPromptAssembly, JsonRecord, ResourceBundleRef } from "../common/types.js";
import { stableHash } from "../common/validation.js";
const maxPromptRefBytes = 16 * 1024;
const maxInitialPromptBytes = 64 * 1024;
const skillSummaryChars = 600;
export interface MaterializedResourceBundle {
workspacePath: string;
binPath?: string;
skillsDir?: string;
initialPrompt?: InitialPromptAssembly;
event: JsonRecord;
}
interface MaterializedPromptRef {
name: string;
path: string;
inject: "thread-start";
required: boolean;
text: string;
bytes: number;
sha256: string;
}
interface MaterializedSkillRef {
name: string;
path: string;
aggregateAs: string;
required: boolean;
registryPath: string;
manifestBytes: number;
manifestSha256: string;
summary: string;
}
export async function materializeResourceBundle(resourceBundleRef: ResourceBundleRef | null | undefined, env: NodeJS.ProcessEnv = process.env): Promise<MaterializedResourceBundle | null> {
if (!resourceBundleRef) return null;
const workspaceRoot = path.resolve(env.AGENTRUN_WORKSPACE_ROOT ?? "/home/agentrun/workspaces");
@@ -32,9 +60,14 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir);
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env);
const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []);
const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []);
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
return {
workspacePath,
...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}),
...(skills.skillsDir ? { skillsDir: skills.skillsDir } : {}),
...(initialPrompt ? { initialPrompt } : {}),
event: {
phase: "resource-bundle-materialized",
kind: "git",
@@ -46,6 +79,9 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
subdir: resourceBundleRef.subdir ?? null,
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
toolAliases: toolAliases.event,
skillRefs: skills.event,
promptRefs: prompts.event,
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false },
valuesPrinted: false,
},
};
@@ -68,6 +104,144 @@ async function materializeToolAliases(checkoutPath: string, aliases: NonNullable
return { binPath, event: { count: names.length, names, binPath: pathSummary(binPath), valuesPrinted: false } };
}
async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> {
const items: MaterializedPromptRef[] = [];
const eventItems: JsonRecord[] = [];
let totalBytes = 0;
for (const ref of refs) {
const promptPath = resolveBundlePath(checkoutPath, ref.path, `promptRefs.${ref.name}.path`);
const required = ref.required === true;
let text: string;
try {
text = await readFile(promptPath, "utf8");
} catch (error) {
if (required) throw new AgentRunError("prompt-unavailable", `required resource prompt ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } });
eventItems.push({ name: ref.name, path: ref.path, inject: "thread-start", required, status: "missing", valuesPrinted: false });
continue;
}
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > maxPromptRefBytes) throw new AgentRunError("prompt-too-large", `resource prompt ${ref.name} exceeds the per-file size limit`, { httpStatus: 400, details: { name: ref.name, path: ref.path, bytes, maxPromptRefBytes, valuesPrinted: false } });
totalBytes += bytes;
if (totalBytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled resource prompt exceeds the total size limit", { httpStatus: 400, details: { totalBytes, maxInitialPromptBytes, valuesPrinted: false } });
const sha = sha256Text(text);
items.push({ name: ref.name, path: ref.path, inject: "thread-start", required, text, bytes, sha256: sha });
eventItems.push({ name: ref.name, path: ref.path, inject: "thread-start", required, status: "materialized", sha256: sha, bytes, valuesPrinted: false });
}
return {
items,
event: {
count: refs.length,
materializedCount: items.length,
names: items.map((item) => item.name),
items: eventItems,
totalBytes,
valuesPrinted: false,
},
};
}
async function materializeSkillRefs(checkoutPath: string, workspacePath: string, refs: NonNullable<ResourceBundleRef["skillRefs"]>): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], skillsDir: null, items: [], valuesPrinted: false } };
const skillsDir = path.join(workspacePath, ".agents", "skills");
await mkdir(skillsDir, { recursive: true });
const items: MaterializedSkillRef[] = [];
const eventItems: JsonRecord[] = [];
for (const ref of refs) {
const manifestPath = resolveBundlePath(checkoutPath, ref.path, `skillRefs.${ref.name}.path`);
const required = ref.required === true;
let manifestText: string;
try {
manifestText = await readFile(manifestPath, "utf8");
} catch (error) {
if (required) throw new AgentRunError("skill-unavailable", `required resource skill ${ref.name} is not readable`, { httpStatus: 400, details: { name: ref.name, path: ref.path, error: fileErrorSummary(error), valuesPrinted: false } });
eventItems.push({ name: ref.name, path: ref.path, required, aggregateAs: ref.aggregateAs ?? ref.name, status: "missing", valuesPrinted: false });
continue;
}
const aggregateAs = ref.aggregateAs ?? ref.name;
const sourceRoot = path.dirname(manifestPath);
const targetRoot = path.join(skillsDir, aggregateAs);
if (path.resolve(sourceRoot) !== path.resolve(targetRoot)) {
await rm(targetRoot, { recursive: true, force: true });
await cp(sourceRoot, targetRoot, { recursive: true, force: true, dereference: false });
}
const bytes = Buffer.byteLength(manifestText, "utf8");
const sha = sha256Text(manifestText);
const summary = skillSummary(manifestText);
items.push({ name: ref.name, path: ref.path, aggregateAs, required, registryPath: path.join(targetRoot, "SKILL.md"), manifestBytes: bytes, manifestSha256: sha, summary });
eventItems.push({ name: ref.name, path: ref.path, aggregateAs, required, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(path.join(targetRoot, "SKILL.md")), summary, valuesPrinted: false });
}
return {
items,
skillsDir,
event: {
count: refs.length,
materializedCount: items.length,
names: items.map((item) => item.name),
skillsDir: pathSummary(skillsDir),
items: eventItems,
valuesPrinted: false,
},
};
}
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
if (promptRefs.length === 0 && skillRefs.length === 0) return undefined;
const sections: string[] = [
"AgentRun initial runtime instructions. These instructions are assembled from ResourceBundleRef promptRefs and skillRefs for the first thread-start turn only.",
];
for (const prompt of promptRefs) {
sections.push([`## Resource Prompt: ${prompt.name}`, `path: ${prompt.path}`, prompt.text].join("\n"));
}
if (skillRefs.length > 0) {
const lines = [
"## Resource Skills",
"The following required runtime skills are mounted in the current workspace. Use these bundle skills instead of default model skill guesses.",
...skillRefs.map((skill) => `- ${skill.name}: ${skill.summary || "No summary provided."} manifest=.agents/skills/${skill.aggregateAs}/SKILL.md source=${skill.path} required=${skill.required}`),
];
sections.push(lines.join("\n"));
}
const text = sections.join("\n\n");
const bytes = Buffer.byteLength(text, "utf8");
if (bytes > maxInitialPromptBytes) throw new AgentRunError("prompt-too-large", "assembled initial prompt exceeds the total size limit", { httpStatus: 400, details: { bytes, maxInitialPromptBytes, promptRefCount: promptRefs.length, skillRefCount: skillRefs.length, valuesPrinted: false } });
return {
text,
summary: {
available: true,
bytes,
sha256: sha256Text(text),
promptRefCount: promptRefs.length,
promptRefNames: promptRefs.map((item) => item.name),
skillRefCount: skillRefs.length,
skillRefNames: skillRefs.map((item) => item.name),
valuesPrinted: false,
},
};
}
function skillSummary(text: string): string {
const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*/u.exec(text);
if (frontmatter) {
const descriptionLine = frontmatter[1]?.split(/\r?\n/u).find((line) => /^description\s*:/iu.test(line));
if (descriptionLine) return trimSummary(descriptionLine.replace(/^description\s*:\s*/iu, "").trim().replace(/^['"]|['"]$/gu, ""));
}
const line = text.split(/\r?\n/u).map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#") && entry !== "---");
return trimSummary(line ?? "");
}
function trimSummary(value: string): string {
const normalized = value.replace(/\s+/gu, " ").trim();
return normalized.length > skillSummaryChars ? `${normalized.slice(0, skillSummaryChars)}...` : normalized;
}
function sha256Text(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
function fileErrorSummary(error: unknown): JsonRecord {
const record = typeof error === "object" && error !== null ? error as { code?: unknown; message?: unknown } : {};
return { code: typeof record.code === "string" ? record.code : null, message: typeof record.message === "string" ? redactText(record.message).slice(0, 300) : null };
}
function aliasWrapper(kind: string, target: string): string {
if (kind === "node-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec node ${shellArg(target)} "$@"\n`;
if (kind === "bun-script") return `#!/usr/bin/env sh\n# agentrun-resource-alias-wrapper\nexec bun ${shellArg(target)} "$@"\n`;
+27 -5
View File
@@ -1,7 +1,7 @@
import { RunnerManagerApi, failureKindFromError, terminalStatusForFailure, errorMessage } from "./manager-api.js";
import { createBackendSession, runBackendTurn, type BackendActiveTurnControl, type BackendAdapterOptions, type BackendSession } from "../backend/adapter.js";
import { materializeResourceBundle } from "./resource-bundle.js";
import type { BackendEvent, BackendProfile, CommandRecord, FailureKind, JsonRecord, RunRecord, RunnerRecord, TerminalStatus } from "../common/types.js";
import type { BackendEvent, BackendProfile, CommandRecord, FailureKind, InitialPromptAssembly, JsonRecord, RunRecord, RunnerRecord, TerminalStatus } from "../common/types.js";
import { AgentRunError } from "../common/errors.js";
export interface RunnerOnceOptions extends BackendAdapterOptions {
@@ -66,6 +66,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
const commandResults: CommandExecutionResult[] = [];
let workspacePath: string | undefined;
let resourceEnv: NodeJS.ProcessEnv | undefined;
let initialPrompt: InitialPromptAssembly | undefined;
let materializationAttempted = false;
let materializationFailure: { failureKind: FailureKind; terminalStatus: TerminalStatus; message: string } | null = null;
let backendSession: BackendSession | null = null;
@@ -95,7 +96,8 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, options.env ?? process.env);
if (materialized) {
workspacePath = materialized.workspacePath;
resourceEnv = materialized.binPath ? prependPath(options.env ?? process.env, materialized.binPath) : undefined;
resourceEnv = resourceEnvForMaterialized(options.env ?? process.env, materialized);
initialPrompt = materialized.initialPrompt;
await api.appendEvent(options.runId, { type: "backend_status", payload: { ...materialized.event, commandId: command.id, attemptId, runnerId: runner.id } });
}
} catch (error) {
@@ -106,7 +108,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
const result = materializationFailure
? await reportCommandFailure(api, options.runId, command.id, runner, attemptId, materializationFailure, "runner:resource-bundle")
: await executeCommand(api, withResourceEnv(options, resourceEnv), command, runner, attemptId, workspacePath, backendSession ?? (backendSession = createBackendSession(currentRun, withResourceEnv(options, resourceEnv))));
: await executeCommand(api, withResourceAssembly(options, resourceEnv, initialPrompt), command, runner, attemptId, workspacePath, backendSession ?? (backendSession = createBackendSession(currentRun, withResourceAssembly(options, resourceEnv, initialPrompt))));
commandResults.push(result);
if (options.oneShot === true) {
const run = await api.reportStatus(options.runId, { terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: null });
@@ -133,8 +135,28 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
}
}
function withResourceEnv(options: RunnerOnceOptions, resourceEnv: NodeJS.ProcessEnv | undefined): RunnerOnceOptions {
return resourceEnv ? { ...options, env: resourceEnv } : options;
function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.ProcessEnv | undefined, initialPrompt: InitialPromptAssembly | undefined): RunnerOnceOptions {
return {
...options,
...(resourceEnv ? { env: resourceEnv } : {}),
...(initialPrompt ? { initialPrompt } : {}),
};
}
function resourceEnvForMaterialized(env: NodeJS.ProcessEnv, materialized: Awaited<ReturnType<typeof materializeResourceBundle>>): NodeJS.ProcessEnv | undefined {
if (!materialized) return undefined;
let next: NodeJS.ProcessEnv | undefined;
if (materialized.binPath) next = prependPath(env, materialized.binPath);
if (materialized.skillsDir) {
const base = next ?? { ...env };
const previous = base.AGENTRUN_SKILLS_DIRS;
next = {
...base,
AGENTRUN_SKILLS_DIRS: previous && previous.trim().length > 0 ? `${materialized.skillsDir}${pathDelimiter()}${previous}` : materialized.skillsDir,
HWLAB_CODE_AGENT_SKILLS_DIRS: materialized.skillsDir,
};
}
return next;
}
function prependPath(env: NodeJS.ProcessEnv, binPath: string): NodeJS.ProcessEnv {
+81 -4
View File
@@ -11,7 +11,7 @@ import type { JsonRecord, ResourceBundleRef } from "../../common/types.js";
import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js";
const execFile = promisify(execFileCallback);
type LocalBundle = { repoUrl: string; commitId: string; toolAliases?: ResourceBundleRef["toolAliases"] };
type LocalBundle = { repoUrl: string; commitId: string; toolAliases?: ResourceBundleRef["toolAliases"]; promptRefs?: ResourceBundleRef["promptRefs"]; skillRefs?: ResourceBundleRef["skillRefs"] };
const selfTest: SelfTestCase = async (context) => {
const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8");
@@ -45,6 +45,11 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
try {
const client = new ManagerClient(server.baseUrl);
const bundle = await createLocalGitBundle(context);
const assemblyBundle: LocalBundle = {
...bundle,
promptRefs: [{ name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true }],
skillRefs: [{ name: "device-pod-cli", path: "skills/device-pod-cli/SKILL.md", required: true, aggregateAs: "device-pod-cli" }],
};
const first = await createHwlabRun(client, context, bundle, "hwlab-session-1", "hello bundle", "hwlab-command-1");
const created = await client.post(`/api/v1/runs/${first.runId}/runner-jobs`, { commandId: first.commandId, idempotencyKey: "hwlab-trace-1" }) as JsonRecord;
const replay = await client.post(`/api/v1/runs/${first.runId}/runner-jobs`, { commandId: first.commandId, idempotencyKey: "hwlab-trace-1" }) as JsonRecord;
@@ -97,6 +102,49 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.deepEqual(((materialized.toolAliases as JsonRecord).names), ["hwpod"]);
assertNoSecretLeak(resultEnvelope);
const assemblyRun = await createHwlabRun(client, context, assemblyBundle, "hwlab-session-assembly", "list visible bundle skills without tools", "hwlab-command-assembly-1");
const assemblyInputFile = path.join(context.tmp, "fake-codex-turn-input-assembly.jsonl");
const assemblyRunner = runOnce({ managerUrl: server.baseUrl, runId: assemblyRun.runId, commandId: assemblyRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-assembly"), AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE: assemblyInputFile }, idleTimeoutMs: 500, pollIntervalMs: 50 });
await waitForCommandState(client, assemblyRun.runId, assemblyRun.commandId, "completed");
const assemblySecond = await client.post(`/api/v1/runs/${assemblyRun.runId}/commands`, { type: "turn", payload: { prompt: "second turn should resume without initial prompt", traceId: "hwlab-command-assembly-2" }, idempotencyKey: "hwlab-command-assembly-2" }) as { id: string };
await waitForCommandState(client, assemblyRun.runId, assemblySecond.id, "completed");
const assemblyRunnerResult = await assemblyRunner as JsonRecord;
assert.equal(assemblyRunnerResult.commandsProcessed, 2);
const assemblyInputs = (await readTextIfExists(assemblyInputFile)).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as JsonRecord);
assert.equal(assemblyInputs.length, 2);
const firstAssemblyInput = turnInputText(assemblyInputs[0]);
const secondAssemblyInput = turnInputText(assemblyInputs[1]);
assert.match(firstAssemblyInput, /HWLAB v0\.2 runtime prompt self-test/u);
assert.match(firstAssemblyInput, /device-pod-cli/u);
assert.match(firstAssemblyInput, /hwpod/u);
assert.match(firstAssemblyInput, /list visible bundle skills/u);
assert.match(secondAssemblyInput, /second turn should resume/u);
assert.doesNotMatch(secondAssemblyInput, /HWLAB v0\.2 runtime prompt self-test/u);
assert.doesNotMatch(secondAssemblyInput, /Resource Skills/u);
const assemblyEventsResponse = await client.get(`/api/v1/runs/${assemblyRun.runId}/events?afterSeq=0&limit=200`) as { items?: Array<{ type?: string; payload?: JsonRecord }> };
const assemblyEvents = assemblyEventsResponse.items ?? [];
assert.ok(assemblyEvents.some((event) => event.type === "backend_status" && event.payload?.phase === "initial-prompt-assembly" && event.payload?.commandId === assemblyRun.commandId && event.payload?.initialPromptInjected === true));
assert.ok(assemblyEvents.some((event) => event.type === "backend_status" && event.payload?.phase === "initial-prompt-assembly" && event.payload?.commandId === assemblySecond.id && event.payload?.initialPromptInjected === false && event.payload?.reason === "thread-resume"));
const assemblyEnvelope = await client.get(`/api/v1/runs/${assemblyRun.runId}/commands/${assemblyRun.commandId}/result`) as JsonRecord;
const assemblyResource = assemblyEnvelope.resourceBundleRef as JsonRecord;
assert.deepEqual(((assemblyResource.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
assert.deepEqual(((assemblyResource.skillRefs as JsonRecord).names), ["device-pod-cli"]);
const assemblyMaterialized = assemblyResource.materialized as JsonRecord;
assert.deepEqual(((assemblyMaterialized.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
assert.deepEqual(((assemblyMaterialized.skillRefs as JsonRecord).names), ["device-pod-cli"]);
assert.equal(((assemblyMaterialized.initialPrompt as JsonRecord).available), true);
assertNoSecretLeak(assemblyEnvelope);
const missingPromptRun = await createHwlabRun(client, context, { ...bundle, promptRefs: [{ name: "missing-prompt", path: "internal/agent/prompts/missing.md", inject: "thread-start", required: true }] }, "hwlab-session-missing-prompt", "missing prompt", "hwlab-command-missing-prompt");
const missingPromptResult = await runOnce({ managerUrl: server.baseUrl, runId: missingPromptRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-missing-prompt") }, oneShot: true }) as JsonRecord;
assert.equal(missingPromptResult.terminalStatus, "blocked");
assert.equal(missingPromptResult.failureKind, "prompt-unavailable");
const missingSkillRun = await createHwlabRun(client, context, { ...bundle, skillRefs: [{ name: "missing-skill", path: "skills/missing-skill/SKILL.md", required: true }] }, "hwlab-session-missing-skill", "missing skill", "hwlab-command-missing-skill");
const missingSkillResult = await runOnce({ managerUrl: server.baseUrl, runId: missingSkillRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-missing-skill") }, oneShot: true }) as JsonRecord;
assert.equal(missingSkillResult.terminalStatus, "blocked");
assert.equal(missingSkillResult.failureKind, "skill-unavailable");
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
const resumedRun = await client.get(`/api/v1/runs/${resumed.runId}`) as JsonRecord;
assert.equal(((resumedRun.sessionRef as JsonRecord).threadId), "thread_selftest_1");
@@ -155,7 +203,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const runningResult = await running;
assert.equal(runningResult.terminalStatus, "cancelled");
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-bundle-materialization", "resource-bundle-tool-alias", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-bundle-materialization", "resource-bundle-tool-alias", "resource-prompt-skill-assembly", "resource-prompt-skill-required-blockers", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -168,7 +216,23 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8");
await mkdir(path.join(repo, "tools"), { recursive: true });
await writeFile(path.join(repo, "tools", "device-pod-cli.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-selftest', argv: process.argv.slice(2) }));\n", "utf8");
await execFile("git", ["add", "README.md", "tools/device-pod-cli.mjs"], { cwd: repo });
await mkdir(path.join(repo, "internal", "agent", "prompts"), { recursive: true });
await writeFile(path.join(repo, "internal", "agent", "prompts", "hwlab-v02-runtime.md"), [
"HWLAB v0.2 runtime prompt self-test",
"Use the hwpod alias for device-pod work.",
"Do not invent fallback hardware paths.",
].join("\n"), "utf8");
await mkdir(path.join(repo, "skills", "device-pod-cli", "scripts"), { recursive: true });
await writeFile(path.join(repo, "skills", "device-pod-cli", "SKILL.md"), [
"---",
"name: device-pod-cli",
"description: Use hwpod for HWLAB device-pod compile, status, job polling, and output inspection.",
"---",
"# device-pod-cli",
"Run `hwpod` from PATH for all device-pod operations.",
].join("\n"), "utf8");
await writeFile(path.join(repo, "skills", "device-pod-cli", "scripts", "device-pod-cli.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'device-pod-skill-selftest' }));\n", "utf8");
await execFile("git", ["add", "README.md", "tools/device-pod-cli.mjs", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/device-pod-cli/SKILL.md", "skills/device-pod-cli/scripts/device-pod-cli.mjs"], { cwd: repo });
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
return { repoUrl: repo, commitId: stdout.trim() };
@@ -176,12 +240,15 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<LocalBund
async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> {
const toolAliases = bundle.toolAliases ?? [{ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }];
const resourceBundleRef: ResourceBundleRef = { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false };
if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs;
if (bundle.skillRefs) resourceBundleRef.skillRefs = bundle.skillRefs;
const run = await client.post("/api/v1/runs", {
tenantId: "hwlab",
projectId: "pikasTech/HWLAB",
workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB" },
sessionRef: { sessionId, conversationId: sessionId },
resourceBundleRef: { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false },
resourceBundleRef,
providerId: "G14",
backendProfile: "codex",
executionPolicy: {
@@ -220,6 +287,16 @@ async function readTextIfExists(filePath: string): Promise<string> {
}
}
function turnInputText(record: JsonRecord | undefined): string {
const input = record?.input;
if (!Array.isArray(input)) return "";
return input.flatMap((item) => {
if (typeof item !== "object" || item === null || Array.isArray(item)) return [];
const text = (item as JsonRecord).text;
return typeof text === "string" ? [text] : [];
}).join("\n");
}
function runnerEnvValue(manifest: JsonRecord, name: string): unknown {
const spec = manifest.spec as JsonRecord;
const template = spec.template as JsonRecord;
+1
View File
@@ -57,6 +57,7 @@ for await (const line of rl) {
continue;
}
if (message.method === "turn/start") {
if (process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE) appendFileSync(process.env.AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE, `${JSON.stringify({ threadId: message.params?.threadId ?? null, input: message.params?.input ?? null })}\n`);
if (mode === "reject-unexpected-model" && (observedThreadModel || Object.hasOwn(message.params ?? {}, "model"))) {
respond(message.id, null, { code: -32000, message: "turn/start unexpectedly included model" });
continue;