mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
refactor: share agent harness loader helpers
This commit is contained in:
100
packages/agent-core/src/harness/file-loader-utils.ts
Normal file
100
packages/agent-core/src/harness/file-loader-utils.ts
Normal 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(/^\/+/, "");
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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(/^\/+/, "");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user