diff --git a/.pi/iop-rule-loader.ts b/.pi/iop-rule-loader.ts index a26b0cb..6f31cab 100644 --- a/.pi/iop-rule-loader.ts +++ b/.pi/iop-rule-loader.ts @@ -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) => ` + .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} -`, - ) +`; + }) .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(""); + }); + 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, + "응답 언어 정책에 맞지 않는 출력이 다시 감지되었습니다. 이 요청은 한국어 응답 안정성이 더 높은 모델로 전환해 다시 실행해야 합니다.", + ), + }; + }); }