From e95fbc05aa611eacea68a1bfe78309f9639e72b6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 29 May 2026 16:22:41 +0200 Subject: [PATCH] refactor: share agent harness loader helpers --- .../src/harness/file-loader-utils.ts | 100 ++++++++ .../src/harness/prompt-templates.ts | 78 +----- packages/agent-core/src/harness/skills.ts | 98 +------- src/agents/sessions/messages.ts | 222 ++---------------- 4 files changed, 137 insertions(+), 361 deletions(-) create mode 100644 packages/agent-core/src/harness/file-loader-utils.ts diff --git a/packages/agent-core/src/harness/file-loader-utils.ts b/packages/agent-core/src/harness/file-loader-utils.ts new file mode 100644 index 00000000000..5ef3791addb --- /dev/null +++ b/packages/agent-core/src/harness/file-loader-utils.ts @@ -0,0 +1,100 @@ +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type Result, toError } from "./types.js"; + +export interface FileInfoDiagnostic { + type: "warning"; + code: "file_info_failed"; + message: string; + path: string; +} + +interface FileInfoDiagnostics { + push(diagnostic: FileInfoDiagnostic): unknown; +} + +export function parseFrontmatter( + content: string, +): Result<{ frontmatter: Record; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { ok: true, value: { frontmatter: {}, body: normalized } }; + } + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { + ok: true, + value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, + }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +export async function resolveFileInfoKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: FileInfoDiagnostics, +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") { + return info.kind; + } + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" + ? target.value.kind + : undefined; +} + +export function joinEnvPath(base: string, child: string): string { + return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; +} + +export function dirnameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); +} + +export function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +export function relativeEnvPath(root: string, path: string): string { + const normalizedRoot = root.replace(/\/+$/, ""); + const normalizedPath = path.replace(/\/+$/, ""); + if (normalizedPath === normalizedRoot) { + return ""; + } + return normalizedPath.startsWith(`${normalizedRoot}/`) + ? normalizedPath.slice(normalizedRoot.length + 1) + : normalizedPath.replace(/^\/+/, ""); +} diff --git a/packages/agent-core/src/harness/prompt-templates.ts b/packages/agent-core/src/harness/prompt-templates.ts index c74bd18b865..41c1c59b91a 100644 --- a/packages/agent-core/src/harness/prompt-templates.ts +++ b/packages/agent-core/src/harness/prompt-templates.ts @@ -1,11 +1,9 @@ -import { parse } from "yaml"; import { - type ExecutionEnv, - type FileInfo, - type PromptTemplate, - type Result, - toError, -} from "./types.js"; + basenameEnvPath, + parseFrontmatter, + resolveFileInfoKind as resolveKind, +} from "./file-loader-utils.js"; +import { type ExecutionEnv, type PromptTemplate, type Result } from "./types.js"; export type PromptTemplateDiagnosticCode = | "file_info_failed" @@ -190,72 +188,6 @@ async function loadTemplateFromFile( }; } -async function resolveKind( - env: ExecutionEnv, - info: FileInfo, - diagnostics: PromptTemplateDiagnostic[], -): Promise<"file" | "directory" | undefined> { - if (info.kind === "file" || info.kind === "directory") { - return info.kind; - } - const canonicalPath = await env.canonicalPath(info.path); - if (!canonicalPath.ok) { - if (canonicalPath.error.code !== "not_found") { - diagnostics.push({ - type: "warning", - code: "file_info_failed", - message: canonicalPath.error.message, - path: info.path, - }); - } - return undefined; - } - const target = await env.fileInfo(canonicalPath.value); - if (!target.ok) { - if (target.error.code !== "not_found") { - diagnostics.push({ - type: "warning", - code: "file_info_failed", - message: target.error.message, - path: info.path, - }); - } - return undefined; - } - return target.value.kind === "file" || target.value.kind === "directory" - ? target.value.kind - : undefined; -} - -function parseFrontmatter( - content: string, -): Result<{ frontmatter: Record; body: string }, Error> { - try { - const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (!normalized.startsWith("---")) { - return { ok: true, value: { frontmatter: {}, body: normalized } }; - } - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { - return { ok: true, value: { frontmatter: {}, body: normalized } }; - } - const yamlString = normalized.slice(4, endIndex); - const body = normalized.slice(endIndex + 4).trim(); - return { - ok: true, - value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, - }; - } catch (error) { - return { ok: false, error: toError(error) }; - } -} - -function basenameEnvPath(path: string): string { - const normalized = path.replace(/\/+$/, ""); - const slashIndex = normalized.lastIndexOf("/"); - return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); -} - /** Parse an argument string using simple shell-style single and double quotes. */ export function parseCommandArgs(argsString: string): string[] { const args: string[] = []; diff --git a/packages/agent-core/src/harness/skills.ts b/packages/agent-core/src/harness/skills.ts index cb7f9819e57..658423dff6f 100644 --- a/packages/agent-core/src/harness/skills.ts +++ b/packages/agent-core/src/harness/skills.ts @@ -1,6 +1,13 @@ import ignore from "ignore"; -import { parse } from "yaml"; -import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.js"; +import { + basenameEnvPath, + dirnameEnvPath, + joinEnvPath, + parseFrontmatter, + relativeEnvPath, + resolveFileInfoKind as resolveKind, +} from "./file-loader-utils.js"; +import { type ExecutionEnv, type Result, type Skill } from "./types.js"; const MAX_NAME_LENGTH = 64; const MAX_DESCRIPTION_LENGTH = 1024; @@ -374,90 +381,3 @@ function validateDescription(description: string | undefined): string[] { } return errors; } - -function parseFrontmatter( - content: string, -): Result<{ frontmatter: Record; body: string }, Error> { - try { - const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - if (!normalized.startsWith("---")) { - return { ok: true, value: { frontmatter: {}, body: normalized } }; - } - const endIndex = normalized.indexOf("\n---", 3); - if (endIndex === -1) { - return { ok: true, value: { frontmatter: {}, body: normalized } }; - } - const yamlString = normalized.slice(4, endIndex); - const body = normalized.slice(endIndex + 4).trim(); - return { - ok: true, - value: { frontmatter: (parse(yamlString) ?? {}) as Record, body }, - }; - } catch (error) { - return { ok: false, error: toError(error) }; - } -} - -async function resolveKind( - env: ExecutionEnv, - info: FileInfo, - diagnostics: SkillDiagnostic[], -): Promise<"file" | "directory" | undefined> { - if (info.kind === "file" || info.kind === "directory") { - return info.kind; - } - const canonicalPath = await env.canonicalPath(info.path); - if (!canonicalPath.ok) { - if (canonicalPath.error.code !== "not_found") { - diagnostics.push({ - type: "warning", - code: "file_info_failed", - message: canonicalPath.error.message, - path: info.path, - }); - } - return undefined; - } - const target = await env.fileInfo(canonicalPath.value); - if (!target.ok) { - if (target.error.code !== "not_found") { - diagnostics.push({ - type: "warning", - code: "file_info_failed", - message: target.error.message, - path: info.path, - }); - } - return undefined; - } - return target.value.kind === "file" || target.value.kind === "directory" - ? target.value.kind - : undefined; -} - -function joinEnvPath(base: string, child: string): string { - return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; -} - -function dirnameEnvPath(path: string): string { - const normalized = path.replace(/\/+$/, ""); - const slashIndex = normalized.lastIndexOf("/"); - return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); -} - -function basenameEnvPath(path: string): string { - const normalized = path.replace(/\/+$/, ""); - const slashIndex = normalized.lastIndexOf("/"); - return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); -} - -function relativeEnvPath(root: string, path: string): string { - const normalizedRoot = root.replace(/\/+$/, ""); - const normalizedPath = path.replace(/\/+$/, ""); - if (normalizedPath === normalizedRoot) { - return ""; - } - return normalizedPath.startsWith(`${normalizedRoot}/`) - ? normalizedPath.slice(normalizedRoot.length + 1) - : normalizedPath.replace(/^\/+/, ""); -} diff --git a/src/agents/sessions/messages.ts b/src/agents/sessions/messages.ts index b21361c96ca..6c4aa81ed48 100644 --- a/src/agents/sessions/messages.ts +++ b/src/agents/sessions/messages.ts @@ -1,72 +1,29 @@ -/** - * Custom message types and transformers for the coding agent. - * - * Extends the base AgentMessage type with coding-agent specific message types, - * and provides a transformer to convert them to LLM-compatible messages. - */ +import type { + BashExecutionMessage, + BranchSummaryMessage, + CompactionSummaryMessage, + CustomMessage, +} from "../../../packages/agent-core/src/harness/messages.js"; -import type { ImageContent, Message, TextContent } from "../../llm/types.js"; -import type { AgentMessage } from "../runtime/index.js"; +export { + bashExecutionToText, + BRANCH_SUMMARY_PREFIX, + BRANCH_SUMMARY_SUFFIX, + COMPACTION_SUMMARY_PREFIX, + COMPACTION_SUMMARY_SUFFIX, + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../../../packages/agent-core/src/harness/messages.js"; -export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: +export type { + BashExecutionMessage, + BranchSummaryMessage, + CompactionSummaryMessage, + CustomMessage, +} from "../../../packages/agent-core/src/harness/messages.js"; - -`; - -export const COMPACTION_SUMMARY_SUFFIX = ` -`; - -export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: - - -`; - -export const BRANCH_SUMMARY_SUFFIX = ``; - -/** - * Message type for bash executions via the ! command. - */ -export interface BashExecutionMessage { - role: "bashExecution"; - command: string; - output: string; - exitCode: number | undefined; - cancelled: boolean; - truncated: boolean; - fullOutputPath?: string; - timestamp: number; - /** If true, this message is excluded from LLM context (!! prefix) */ - excludeFromContext?: boolean; -} - -/** - * Message type for extension-injected messages via sendMessage(). - * These are custom messages that extensions can inject into the conversation. - */ -export interface CustomMessage { - role: "custom"; - customType: string; - content: string | (TextContent | ImageContent)[]; - display: boolean; - details?: T; - timestamp: number; -} - -export interface BranchSummaryMessage { - role: "branchSummary"; - summary: string; - fromId: string; - timestamp: number; -} - -export interface CompactionSummaryMessage { - role: "compactionSummary"; - summary: string; - tokensBefore: number; - timestamp: number; -} - -// Extend CustomAgentMessages via declaration merging declare module "openclaw/plugin-sdk/agent-core" { interface CustomAgentMessages { bashExecution: BashExecutionMessage; @@ -75,136 +32,3 @@ declare module "openclaw/plugin-sdk/agent-core" { compactionSummary: CompactionSummaryMessage; } } - -/** - * Convert a BashExecutionMessage to user message text for LLM context. - */ -export function bashExecutionToText(msg: BashExecutionMessage): string { - let text = `Ran \`${msg.command}\`\n`; - if (msg.output) { - text += `\`\`\`\n${msg.output}\n\`\`\``; - } else { - text += "(no output)"; - } - if (msg.cancelled) { - text += "\n\n(command cancelled)"; - } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { - text += `\n\nCommand exited with code ${msg.exitCode}`; - } - if (msg.truncated && msg.fullOutputPath) { - text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; - } - return text; -} - -export function createBranchSummaryMessage( - summary: string, - fromId: string, - timestamp: string, -): BranchSummaryMessage { - return { - role: "branchSummary", - summary, - fromId, - timestamp: new Date(timestamp).getTime(), - }; -} - -export function createCompactionSummaryMessage( - summary: string, - tokensBefore: number, - timestamp: string, -): CompactionSummaryMessage { - return { - role: "compactionSummary", - summary: summary, - tokensBefore, - timestamp: new Date(timestamp).getTime(), - }; -} - -/** Convert CustomMessageEntry to AgentMessage format */ -export function createCustomMessage( - customType: string, - content: string | (TextContent | ImageContent)[], - display: boolean, - details: unknown, - timestamp: string, -): CustomMessage { - return { - role: "custom", - customType, - content, - display, - details, - timestamp: new Date(timestamp).getTime(), - }; -} - -/** - * Transform AgentMessages (including custom types) to LLM-compatible Messages. - * - * This is used by: - * - Agent's transormToLlm option (for prompt calls and queued messages) - * - Compaction's generateSummary (for summarization) - * - Custom extensions and tools - */ -export function convertToLlm(messages: AgentMessage[]): Message[] { - return messages - .map((m): Message | undefined => { - switch (m.role) { - case "bashExecution": - // Skip messages excluded from context (!! prefix) - if (m.excludeFromContext) { - return undefined; - } - return { - role: "user", - content: [{ type: "text", text: bashExecutionToText(m) }], - timestamp: m.timestamp, - }; - case "custom": { - const content = - typeof m.content === "string" - ? [{ type: "text" as const, text: m.content }] - : m.content; - return { - role: "user", - content, - timestamp: m.timestamp, - }; - } - case "branchSummary": - return { - role: "user", - content: [ - { - type: "text" as const, - text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX, - }, - ], - timestamp: m.timestamp, - }; - case "compactionSummary": - return { - role: "user", - content: [ - { - type: "text" as const, - text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX, - }, - ], - timestamp: m.timestamp, - }; - case "user": - case "assistant": - case "toolResult": - return m; - default: - // biome-ignore lint/correctness/noSwitchDeclarations: fine - m satisfies never; - return undefined; - } - }) - .filter((m) => m !== undefined); -}