diff --git a/src/backend/adapter.ts b/src/backend/adapter.ts index 905ff05..8ca51bd 100644 --- a/src/backend/adapter.ts +++ b/src/backend/adapter.ts @@ -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; 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; diff --git a/src/backend/codex-stdio.ts b/src/backend/codex-stdio.ts index 8e0203c..c89db65 100644 --- a/src/backend/codex-stdio.ts +++ b/src/backend/codex-stdio.ts @@ -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; 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: [ + "", + initialPrompt.text, + "", + ].join("\n"), + injected: true, + reason: "thread-start", + }; +} + +function textInputForUserMessage(prompt: string, initial: ReturnType): JsonValue[] { + if (!initial.injected) return textInput(prompt); + return textInput([ + initial.text, + "", + prompt, + "", + ].join("\n")); +} + function agentMessageText(item: JsonRecord): string { for (const key of ["text", "content", "message"]) { const value = item[key]; diff --git a/src/common/types.ts b/src/common/types.ts index 40aa093..288e465 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -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; diff --git a/src/common/validation.ts b/src/common/validation.ts index 27e0d94..fdcd698 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -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 { + 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(); + 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 { + 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(); + 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 }); diff --git a/src/mgr/result.ts b/src/mgr/result.ts index 672755c..21ff1b2 100644 --- a/src/mgr/result.ts +++ b/src/mgr/result.ts @@ -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, }; } diff --git a/src/mgr/store.ts b/src/mgr/store.ts index 57917f0..0ca2a02 100644 --- a/src/mgr/store.ts +++ b/src/mgr/store.ts @@ -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, diff --git a/src/runner/manager-api.ts b/src/runner/manager-api.ts index 4149fa5..b62a2cf 100644 --- a/src/runner/manager-api.ts +++ b/src/runner/manager-api.ts @@ -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"; } diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 97fe461..f4aa500 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -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 { 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): 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): 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`; diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index ba90150..6dfc92a 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -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 { 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 { 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 { 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 { } } -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>): 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 { diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index 8802265..56157bc 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -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((resolve) => server.server.close(() => resolve())); } @@ -168,7 +216,23 @@ async function createLocalGitBundle(context: SelfTestContext): Promise { 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 { } } +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; diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index 977911d..be7fd78 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -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;