chore: update iop-rule-loader

This commit is contained in:
toki 2026-07-04 10:44:34 +09:00
parent 4cd214f53c
commit fa3eb5f939

View file

@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
type RuleSpec = {
relativePath: string;
enabled?: (projectRoot: string) => boolean;
};
type LoadedRule = {
@ -11,10 +12,49 @@ type LoadedRule = {
content: string;
};
const entryRules: RuleSpec[] = [
type ProviderMessage = {
role?: string;
content?: unknown;
[key: string]: unknown;
};
type ProviderPayload = {
messages?: ProviderMessage[];
[key: string]: unknown;
};
type PiMessage = {
role?: string;
content?: unknown;
[key: string]: unknown;
};
const commonRules: RuleSpec[] = [
{ relativePath: "agent-ops/rules/common/rules.md" },
];
const responseLanguagePolicy = `## IOP Response Language
.
.
, , , .`;
const responseLanguageUserPrefix = `[IOP 응답 언어: 이번 요청에서 사용자가 다른 언어를 명시하지 않았다면 최종 답변의 자연어 문장은 반드시 한국어로 작성한다. 코드, 명령어, 파일 경로, 식별자는 원문을 유지한다.]`;
const responseLanguageUserSuffix = `[응답 전 확인: 자연어 설명이 한국어인지 확인하고, 한국어가 아니면 한국어로 다시 작성한다.]`;
const responseLanguageRetryPrefix = "[IOP language retry]";
const nonKoreanCJKPattern = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]/;
const commonRequiredSessionRules: RuleSpec[] = [
{ relativePath: "agent-ops/rules/project/rules.md" },
{ relativePath: "agent-ops/rules/private/rules.md" },
{
relativePath: "agent-ops/rules/common/rules-roadmap.md",
enabled: (projectRoot) => fs.existsSync(path.join(projectRoot, "agent-roadmap")),
},
];
function findProjectRoot(startDir: string): string {
let current = path.resolve(startDir);
@ -33,7 +73,11 @@ function findProjectRoot(startDir: string): string {
}
function loadEntryRules(projectRoot: string): LoadedRule[] {
return entryRules.flatMap((rule) => {
return [...commonRules, ...commonRequiredSessionRules].flatMap((rule) => {
if (rule.enabled && !rule.enabled(projectRoot)) {
return [];
}
const fullPath = path.join(projectRoot, rule.relativePath);
if (!fs.existsSync(fullPath)) {
return [];
@ -50,23 +94,218 @@ function loadEntryRules(projectRoot: string): LoadedRule[] {
function buildRulesPrompt(projectRoot: string, rules: LoadedRule[]): string {
const blocks = rules
.map(
(rule) => `<common_rule path="${rule.relativePath}">
.map((rule) => {
const tag = rule.relativePath === "agent-ops/rules/common/rules.md"
? "common_rule"
: "common_required_session_rule";
return `<${tag} path="${rule.relativePath}">
${rule.content}
</common_rule>`,
)
</${tag}>`;
})
.join("\n\n");
return `## Agent-Ops Common Rules
return `## Agent-Ops Common Rules Bootstrap
The following common rule entry file was loaded automatically from ${projectRoot}. Treat this common rule as already read and follow it together with AGENTS.md. When the common rule names additional session, domain, roadmap, test, or skill rules, load those files on demand when their trigger conditions apply.
turn ${projectRoot} .
- agent-ops/rules/common/rules.md
- agent-ops/rules/project/rules.md,
- agent-ops/rules/private/rules.md,
- agent-ops/rules/common/rules-roadmap.md, agent-roadmap/
. AGENTS.md와 . domain rule, skill, contract, test rule, agent-ui rule, philosophy, archive .
${blocks}`;
}
function contentHasPolicy(content: unknown): boolean {
if (typeof content === "string") {
return content.includes("## IOP Response Language");
}
if (Array.isArray(content)) {
return content.some((item) => {
if (typeof item === "string") {
return item.includes("## IOP Response Language");
}
if (item && typeof item === "object" && "text" in item) {
return String((item as { text?: unknown }).text ?? "").includes("## IOP Response Language");
}
return false;
});
}
return false;
}
function contentHasUserPolicy(content: unknown): boolean {
return contentAsText(content).includes(responseLanguageUserPrefix);
}
function appendSystemPolicyToContent(content: unknown): unknown {
if (typeof content === "string") {
return `${content}
${responseLanguagePolicy}`;
}
if (Array.isArray(content)) {
return [
...content,
{
type: "text",
text: responseLanguagePolicy,
},
];
}
return responseLanguagePolicy;
}
function wrapUserPolicyAroundContent(content: unknown): unknown {
if (typeof content === "string") {
return `${responseLanguageUserPrefix}
${content}
${responseLanguageUserSuffix}`;
}
if (Array.isArray(content)) {
return [
{
type: "text",
text: responseLanguageUserPrefix,
},
...content,
{
type: "text",
text: responseLanguageUserSuffix,
},
];
}
return `${responseLanguageUserPrefix}
${responseLanguageUserSuffix}`;
}
function contentAsText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === "string") {
return item;
}
if (item && typeof item === "object" && "text" in item) {
return String((item as { text?: unknown }).text ?? "");
}
return "";
})
.join("\n");
}
return "";
}
function messageText(message: PiMessage): string {
return contentAsText(message.content);
}
function messageHasToolCall(message: PiMessage): boolean {
if (!Array.isArray(message.content)) {
return false;
}
return message.content.some((item) => {
if (!item || typeof item !== "object") {
return false;
}
const type = String((item as { type?: unknown }).type ?? "");
return type === "toolCall" || type === "tool_call";
});
}
function replaceMessageText(message: PiMessage, text: string): PiMessage {
return {
...message,
content: [
{
type: "text",
text,
},
],
};
}
function injectResponseLanguagePolicy(payload: ProviderPayload): ProviderPayload | undefined {
const messages = payload.messages;
if (!Array.isArray(messages)) {
return undefined;
}
const systemIndex = messages.findIndex((message) => message.role === "system" || message.role === "developer");
const userIndex = messages.findLastIndex((message) => {
if (message.role !== "user") {
return false;
}
return !contentAsText(message.content).trimStart().startsWith("<tool_response>");
});
const needsSystemPolicy = systemIndex < 0 || !contentHasPolicy(messages[systemIndex].content);
const needsUserPolicy = userIndex >= 0 && !contentHasUserPolicy(messages[userIndex].content);
if (!needsSystemPolicy && !needsUserPolicy) {
return undefined;
}
const nextMessages = [...messages];
let nextUserIndex = userIndex;
if (needsSystemPolicy && systemIndex >= 0) {
const systemMessage = nextMessages[systemIndex];
nextMessages[systemIndex] = {
...systemMessage,
content: appendSystemPolicyToContent(systemMessage.content),
};
} else if (needsSystemPolicy) {
nextMessages.unshift({
role: "system",
content: responseLanguagePolicy,
});
if (nextUserIndex >= 0) {
nextUserIndex++;
}
}
if (needsUserPolicy) {
const userMessage = nextMessages[nextUserIndex];
nextMessages[nextUserIndex] = {
...userMessage,
content: wrapUserPolicyAroundContent(userMessage.content),
};
}
const nextPayload: ProviderPayload = {
...payload,
messages: nextMessages,
};
if (nextPayload.temperature === undefined) {
nextPayload.temperature = 0;
}
if (nextPayload.top_p === undefined) {
nextPayload.top_p = 1;
}
if (nextPayload.seed === undefined) {
nextPayload.seed = 1;
}
return nextPayload;
}
export default function iopRuleLoader(pi: ExtensionAPI) {
let projectRoot = "";
let loadedRules: LoadedRule[] = [];
let lastUserPrompt = "";
let languageRetryUsed = false;
pi.on("session_start", async (_event, ctx) => {
projectRoot = findProjectRoot(ctx.cwd);
@ -79,6 +318,11 @@ export default function iopRuleLoader(pi: ExtensionAPI) {
});
pi.on("before_agent_start", async (event, ctx) => {
lastUserPrompt = event.prompt;
if (!lastUserPrompt.startsWith(responseLanguageRetryPrefix)) {
languageRetryUsed = false;
}
const root = projectRoot || findProjectRoot(ctx.cwd);
const rules = loadEntryRules(root);
@ -95,4 +339,44 @@ export default function iopRuleLoader(pi: ExtensionAPI) {
${buildRulesPrompt(projectRoot, loadedRules)}`,
};
});
pi.on("before_provider_request", (event) => {
return injectResponseLanguagePolicy(event.payload as ProviderPayload);
});
pi.on("message_end", async (event) => {
const message = event.message as PiMessage;
if (message.role !== "assistant" || messageHasToolCall(message)) {
return;
}
const text = messageText(message);
if (!text || !nonKoreanCJKPattern.test(text)) {
return;
}
if (!languageRetryUsed && lastUserPrompt && !lastUserPrompt.startsWith(responseLanguageRetryPrefix)) {
languageRetryUsed = true;
pi.sendUserMessage(
`${responseLanguageRetryPrefix}
.
, .
, , , .
${lastUserPrompt}`,
{ deliverAs: "followUp" },
);
return {
message: replaceMessageText(message, "응답 언어 정책에 맞게 다시 작성하겠습니다."),
};
}
return {
message: replaceMessageText(
message,
"응답 언어 정책에 맞지 않는 출력이 다시 감지되었습니다. 이 요청은 한국어 응답 안정성이 더 높은 모델로 전환해 다시 실행해야 합니다.",
),
};
});
}