refactor: share agent harness loader helpers

This commit is contained in:
Vincent Koc
2026-05-29 16:22:41 +02:00
parent cde6aff622
commit e95fbc05aa
4 changed files with 137 additions and 361 deletions

View File

@@ -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<string, unknown>; 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<string, unknown>, 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(/^\/+/, "");
}

View File

@@ -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<string, unknown>; 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<string, unknown>, 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[] = [];

View File

@@ -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<string, unknown>; 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<string, unknown>, 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(/^\/+/, "");
}

View File

@@ -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";
<summary>
`;
export const COMPACTION_SUMMARY_SUFFIX = `
</summary>`;
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
<summary>
`;
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
/**
* 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<T = unknown> {
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);
}