merge: real LLM execution for sister-agent
This commit is contained in:
@@ -1 +1 @@
|
||||
1775809049
|
||||
1775814166
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"timestamp": "2026-04-10T08:17:29Z",
|
||||
"changed_file": "/home/erang/hanarang-rails/src/server/http.ts",
|
||||
"timestamp": "2026-04-10T09:46:21Z",
|
||||
"changed_file": "/home/erang/hanarang-rails/src/orchestrator/runner.ts",
|
||||
"test_command": "npm test",
|
||||
"related_test": "",
|
||||
"recommendation": "テストの実行を推奨します"
|
||||
|
||||
108
sister-agent/src/llm.ts
Normal file
108
sister-agent/src/llm.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface LlmResult {
|
||||
ok: boolean;
|
||||
text: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
const OPENCLAW_BIN =
|
||||
process.env["OPENCLAW_BIN"] ??
|
||||
`${process.env["HOME"]}/.npm-global/bin/openclaw`;
|
||||
|
||||
/**
|
||||
* Call openclaw infer model run via subprocess.
|
||||
* Returns the structured JSON the CLI emits with --json.
|
||||
*
|
||||
* Note: openclaw enforces an allowlist per agent. We pass through to the
|
||||
* default model unless an explicit override is requested AND it's allowed.
|
||||
*/
|
||||
export async function callLlm(opts: {
|
||||
prompt: string;
|
||||
modelOverride?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<LlmResult> {
|
||||
const args = ["infer", "model", "run", "--prompt", opts.prompt, "--json"];
|
||||
if (opts.modelOverride) {
|
||||
args.push("--model", opts.modelOverride);
|
||||
}
|
||||
|
||||
return new Promise((resolveFn) => {
|
||||
const child = spawn(OPENCLAW_BIN, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `LLM timeout after ${opts.timeoutMs ?? 120_000}ms`,
|
||||
});
|
||||
}, opts.timeoutMs ?? 120_000);
|
||||
|
||||
child.stdout.on("data", (b: Buffer) => (stdout += b.toString()));
|
||||
child.stderr.on("data", (b: Buffer) => (stderr += b.toString()));
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `LLM spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (code !== 0) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `openclaw exit ${code}: ${stderr.slice(0, 500)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
outputs: Array<{ text: string }>;
|
||||
};
|
||||
const text = parsed.outputs?.[0]?.text ?? "";
|
||||
resolveFn({
|
||||
ok: parsed.ok,
|
||||
text,
|
||||
provider: parsed.provider,
|
||||
model: parsed.model,
|
||||
});
|
||||
} catch (err) {
|
||||
resolveFn({
|
||||
ok: false,
|
||||
text: "",
|
||||
provider: "",
|
||||
model: opts.modelOverride ?? "default",
|
||||
errorMessage: `JSON parse failed: ${err instanceof Error ? err.message : String(err)}\nstdout: ${stdout.slice(0, 500)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
103
sister-agent/src/prompts.ts
Normal file
103
sister-agent/src/prompts.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Role } from "./types.js";
|
||||
|
||||
export interface PromptContext {
|
||||
role: Role;
|
||||
agentName: string; // harang/narang/darang/erang
|
||||
stage: "plan" | "implement" | "review" | "deploy";
|
||||
taskTitle: string;
|
||||
taskDescription: string;
|
||||
prevStageOutput?: string;
|
||||
parentTitle?: string;
|
||||
}
|
||||
|
||||
const STAGE_KOREAN: Record<string, string> = {
|
||||
plan: "기획",
|
||||
implement: "구현",
|
||||
review: "검토",
|
||||
deploy: "배포",
|
||||
};
|
||||
|
||||
const ROLE_KOREAN: Record<Role, string> = {
|
||||
manager: "부장",
|
||||
principal: "수석",
|
||||
lead: "선임",
|
||||
junior: "신입",
|
||||
};
|
||||
|
||||
const ROLE_RESPONSIBILITY: Record<Role, string> = {
|
||||
manager:
|
||||
"팀 전체의 전략을 결정하고 최종 결과물의 품질을 책임진다. 본인이 직접 코드를 짜지 않고 아래 팀에 분배한다.",
|
||||
principal:
|
||||
"기술적 분해와 리뷰를 담당한다. 부장의 방향을 받아 구체적인 실행 단위로 쪼갠다.",
|
||||
lead:
|
||||
"실행 리드. 작은 팀을 조율하면서 신입의 작업물을 검증하고 합친다.",
|
||||
junior:
|
||||
"한 가지 명확한 작업을 직접 실행한다. 결과물(텍스트, 코드, 답변)을 명확하게 제출한다.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the prompt the LLM will see for this node.
|
||||
* The pattern: short system context + concrete task + previous output (if any).
|
||||
*
|
||||
* Output format hint: ask for plain text. Keeping it simple — no JSON parsing
|
||||
* required from the LLM (we already have structure from the spawn tree).
|
||||
*/
|
||||
export function buildPrompt(ctx: PromptContext): string {
|
||||
const stageKor = STAGE_KOREAN[ctx.stage] ?? ctx.stage;
|
||||
const roleKor = ROLE_KOREAN[ctx.role];
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# 역할`);
|
||||
lines.push(
|
||||
`너는 "${ctx.agentName}" 자매의 ${roleKor}(${ctx.role})이다. ${ROLE_RESPONSIBILITY[ctx.role]}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(`# 현재 단계`);
|
||||
lines.push(`${stageKor} (stage=${ctx.stage})`);
|
||||
lines.push("");
|
||||
lines.push(`# 작업`);
|
||||
lines.push(`제목: ${ctx.taskTitle}`);
|
||||
if (ctx.taskDescription) {
|
||||
lines.push(`상세: ${ctx.taskDescription}`);
|
||||
}
|
||||
if (ctx.parentTitle) {
|
||||
lines.push(`상위 작업: ${ctx.parentTitle}`);
|
||||
}
|
||||
if (ctx.prevStageOutput) {
|
||||
lines.push("");
|
||||
lines.push(`# 이전 단계 결과 (참고)`);
|
||||
lines.push(ctx.prevStageOutput.slice(0, 4000));
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`# 출력 형식`);
|
||||
lines.push(roleOutputHint(ctx.role, ctx.stage));
|
||||
lines.push(`반드시 한국어로 답해. 200-400자 내외로 핵심만.`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function roleOutputHint(role: Role, stage: PromptContext["stage"]): string {
|
||||
if (role === "manager") {
|
||||
return `이 작업을 어떻게 분해할지, 어떤 팀(수석/선임/신입)을 어디에 배치할지 한 문단으로 결정해.`;
|
||||
}
|
||||
if (role === "principal") {
|
||||
return `${STAGE_KOREAN[stage]} 단계에서 구체적으로 어떤 리스크가 있고, 어떻게 분해되어야 하는지 bullet 으로 제시해.`;
|
||||
}
|
||||
if (role === "lead") {
|
||||
return `이 작업을 신입에게 어떻게 나눠줄지, 검증 포인트는 무엇인지 bullet 으로 정리해.`;
|
||||
}
|
||||
// junior
|
||||
if (stage === "plan") {
|
||||
return `이 프로젝트의 핵심 plan 을 마크다운 bullet 형식으로 작성해.`;
|
||||
}
|
||||
if (stage === "implement") {
|
||||
return `요구된 코드/파일/내용을 그대로 작성해. 코드면 코드 블록으로.`;
|
||||
}
|
||||
if (stage === "review") {
|
||||
return `위 결과물을 평가하고 APPROVE 또는 REQUEST_CHANGES 로 시작해서 이유를 한 문단.`;
|
||||
}
|
||||
if (stage === "deploy") {
|
||||
return `이 결과물을 어떻게 배포 검증할지 짧게 설명하고 마지막 줄에 "DEPLOY_DONE" 또는 "DEPLOY_FAILED" 표기.`;
|
||||
}
|
||||
return `결과를 명확히 제출해.`;
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { ROLES } from "./roles.js";
|
||||
import { scoreComplexity, type ComplexityScore } from "./complexity.js";
|
||||
import { planDecomposition, type DecompositionPlan } from "./planner.js";
|
||||
import type { RailsClient } from "./rails-client.js";
|
||||
import { callLlm } from "./llm.js";
|
||||
import { buildPrompt } from "./prompts.js";
|
||||
|
||||
const USE_REAL_LLM = process.env["RAILS_USE_REAL_LLM"] !== "false";
|
||||
|
||||
/**
|
||||
* Execute an invocation using the hierarchical team strategy.
|
||||
@@ -95,120 +99,231 @@ async function executeRole(
|
||||
const hasChildren =
|
||||
depth === 0 && plan.spawn.length > 0 && plan.strategy !== "direct";
|
||||
|
||||
// Manager executes its own decision/judgment first
|
||||
const managerWork = await doWork({
|
||||
role,
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: req.task.title,
|
||||
taskDescription: req.task.description,
|
||||
});
|
||||
await persistResult(rails, selfId, managerWork);
|
||||
|
||||
if (!hasChildren) {
|
||||
// Leaf execution — in this simulation we just produce a success result
|
||||
await simulateWork(role);
|
||||
return buildSuccessResult(req.stage, req.task);
|
||||
return buildSuccessResult(req.stage, req.task, managerWork.text);
|
||||
}
|
||||
|
||||
// Aggregate texts from all children to feed back into the final result
|
||||
const childTexts: string[] = [managerWork.text];
|
||||
|
||||
// Spawn children per plan
|
||||
for (const spawnPlan of plan.spawn) {
|
||||
for (let i = 0; i < spawnPlan.count; i++) {
|
||||
const childId = ulid();
|
||||
const childRecord: SubTaskRecord = {
|
||||
const childTitle = `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`;
|
||||
await rails.createSubTask({
|
||||
id: childId,
|
||||
pipelineId: req.pipelineId,
|
||||
parentId: selfId,
|
||||
role: spawnPlan.role,
|
||||
agentName,
|
||||
title: `${spawnPlan.role}-${i + 1}: ${req.task.title.slice(0, 100)}`,
|
||||
title: childTitle,
|
||||
description: spawnPlan.rationale,
|
||||
complexityScore: null,
|
||||
complexityTier: null,
|
||||
model: ROLES[spawnPlan.role].primaryModel,
|
||||
};
|
||||
await rails.createSubTask(childRecord);
|
||||
await rails.recordEvent(childId, "spawned", {
|
||||
parent: selfId,
|
||||
role: spawnPlan.role,
|
||||
});
|
||||
await rails.recordEvent(childId, "spawned", { parent: selfId, role: spawnPlan.role });
|
||||
await rails.recordEvent(childId, "started", {});
|
||||
|
||||
// Recursively spawn grandchildren if subBreakdown exists
|
||||
let childWorkText = "";
|
||||
|
||||
if (spawnPlan.subBreakdown && spawnPlan.subBreakdown.length > 0) {
|
||||
// Principal-level: do its own assessment then spawn leads
|
||||
const principalWork = await doWork({
|
||||
role: spawnPlan.role,
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: childTitle,
|
||||
taskDescription: spawnPlan.rationale,
|
||||
parentTitle: req.task.title,
|
||||
prevStageOutput: managerWork.text,
|
||||
});
|
||||
await persistResult(rails, childId, principalWork);
|
||||
childWorkText = principalWork.text;
|
||||
|
||||
for (const grandSpawn of spawnPlan.subBreakdown) {
|
||||
for (let j = 0; j < grandSpawn.count; j++) {
|
||||
const grandId = ulid();
|
||||
const grandRecord: SubTaskRecord = {
|
||||
const grandTitle = `${grandSpawn.role}-${j + 1}`;
|
||||
await rails.createSubTask({
|
||||
id: grandId,
|
||||
pipelineId: req.pipelineId,
|
||||
parentId: childId,
|
||||
role: grandSpawn.role,
|
||||
agentName,
|
||||
title: `${grandSpawn.role}-${j + 1}`,
|
||||
title: grandTitle,
|
||||
description: grandSpawn.rationale,
|
||||
complexityScore: null,
|
||||
complexityTier: null,
|
||||
model: ROLES[grandSpawn.role].primaryModel,
|
||||
};
|
||||
await rails.createSubTask(grandRecord);
|
||||
await rails.recordEvent(grandId, "spawned", {
|
||||
parent: childId,
|
||||
role: grandSpawn.role,
|
||||
});
|
||||
await rails.recordEvent(grandId, "spawned", { parent: childId, role: grandSpawn.role });
|
||||
await rails.recordEvent(grandId, "started", {});
|
||||
|
||||
// Third-level (junior) grand-grandchildren
|
||||
if (grandSpawn.subBreakdown && grandSpawn.subBreakdown.length > 0) {
|
||||
// Lead does its own work then spawns juniors
|
||||
const leadWork = await doWork({
|
||||
role: grandSpawn.role,
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: grandTitle,
|
||||
taskDescription: grandSpawn.rationale,
|
||||
parentTitle: childTitle,
|
||||
prevStageOutput: principalWork.text,
|
||||
});
|
||||
await persistResult(rails, grandId, leadWork);
|
||||
|
||||
for (const ggSpawn of grandSpawn.subBreakdown) {
|
||||
for (let k = 0; k < ggSpawn.count; k++) {
|
||||
const ggId = ulid();
|
||||
const ggTitle = `${ggSpawn.role}-${k + 1}`;
|
||||
await rails.createSubTask({
|
||||
id: ggId,
|
||||
pipelineId: req.pipelineId,
|
||||
parentId: grandId,
|
||||
role: ggSpawn.role,
|
||||
agentName,
|
||||
title: `${ggSpawn.role}-${k + 1}`,
|
||||
title: ggTitle,
|
||||
description: ggSpawn.rationale,
|
||||
complexityScore: null,
|
||||
complexityTier: null,
|
||||
model: ROLES[ggSpawn.role].primaryModel,
|
||||
});
|
||||
await rails.recordEvent(ggId, "spawned", {
|
||||
parent: grandId,
|
||||
role: ggSpawn.role,
|
||||
});
|
||||
await rails.recordEvent(ggId, "spawned", { parent: grandId, role: ggSpawn.role });
|
||||
await rails.recordEvent(ggId, "started", {});
|
||||
await simulateWork(ggSpawn.role);
|
||||
await rails.recordEvent(ggId, "completed", { ok: true });
|
||||
|
||||
const juniorWork = await doWork({
|
||||
role: ggSpawn.role,
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: ggTitle,
|
||||
taskDescription: ggSpawn.rationale,
|
||||
parentTitle: grandTitle,
|
||||
prevStageOutput: leadWork.text,
|
||||
});
|
||||
await persistResult(rails, ggId, juniorWork);
|
||||
await rails.recordEvent(ggId, "completed", { ok: juniorWork.ok });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await simulateWork(grandSpawn.role);
|
||||
// grandSpawn is a leaf (junior or lead acting alone)
|
||||
const leafWork = await doWork({
|
||||
role: grandSpawn.role,
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: grandTitle,
|
||||
taskDescription: grandSpawn.rationale,
|
||||
parentTitle: childTitle,
|
||||
prevStageOutput: principalWork.text,
|
||||
});
|
||||
await persistResult(rails, grandId, leafWork);
|
||||
}
|
||||
await rails.recordEvent(grandId, "completed", { ok: true });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await simulateWork(spawnPlan.role);
|
||||
// Direct child is leaf — execute work and store
|
||||
const leafWork = await doWork({
|
||||
role: spawnPlan.role,
|
||||
agentName,
|
||||
stage: req.stage,
|
||||
taskTitle: childTitle,
|
||||
taskDescription: spawnPlan.rationale,
|
||||
parentTitle: req.task.title,
|
||||
prevStageOutput: managerWork.text,
|
||||
});
|
||||
await persistResult(rails, childId, leafWork);
|
||||
childWorkText = leafWork.text;
|
||||
}
|
||||
|
||||
childTexts.push(childWorkText);
|
||||
await rails.recordEvent(childId, "completed", { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
void complexity; // reserved for future LLM-based planning
|
||||
return buildSuccessResult(req.stage, req.task);
|
||||
void complexity;
|
||||
return buildSuccessResult(
|
||||
req.stage,
|
||||
req.task,
|
||||
childTexts.filter(Boolean).join("\n\n---\n\n").slice(0, 6000),
|
||||
);
|
||||
}
|
||||
|
||||
async function persistResult(
|
||||
rails: RailsClient,
|
||||
subTaskId: string,
|
||||
work: { ok: boolean; text: string; error?: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await rails.patchSubTask(subTaskId, {
|
||||
resultJson: JSON.stringify({ text: work.text, ok: work.ok }),
|
||||
...(work.error && { errorReason: work.error }),
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder "work" — tiny delay per role so timeline looks realistic.
|
||||
* Replace with real openclaw agent CLI or LLM SDK call.
|
||||
* Run actual work for a node. Calls openclaw infer model run if RAILS_USE_REAL_LLM
|
||||
* is enabled (default). Falls back to short sleep if disabled.
|
||||
*
|
||||
* Returns the LLM text output (or empty if simulation).
|
||||
*/
|
||||
async function simulateWork(role: Role): Promise<void> {
|
||||
const delayByRole: Record<Role, number> = {
|
||||
manager: 40,
|
||||
principal: 60,
|
||||
lead: 80,
|
||||
junior: 100,
|
||||
};
|
||||
await new Promise((r) => setTimeout(r, delayByRole[role]));
|
||||
async function doWork(args: {
|
||||
role: Role;
|
||||
agentName: string;
|
||||
stage: InvokeRequest["stage"];
|
||||
taskTitle: string;
|
||||
taskDescription: string;
|
||||
prevStageOutput?: string;
|
||||
parentTitle?: string;
|
||||
}): Promise<{ ok: boolean; text: string; error?: string }> {
|
||||
if (!USE_REAL_LLM) {
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return { ok: true, text: "" };
|
||||
}
|
||||
|
||||
const prompt = buildPrompt({
|
||||
role: args.role,
|
||||
agentName: args.agentName,
|
||||
stage: args.stage,
|
||||
taskTitle: args.taskTitle,
|
||||
taskDescription: args.taskDescription,
|
||||
...(args.prevStageOutput !== undefined && { prevStageOutput: args.prevStageOutput }),
|
||||
...(args.parentTitle !== undefined && { parentTitle: args.parentTitle }),
|
||||
});
|
||||
|
||||
const result = await callLlm({
|
||||
prompt,
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return { ok: false, text: "", error: result.errorMessage ?? "unknown LLM error" };
|
||||
}
|
||||
return { ok: true, text: result.text };
|
||||
}
|
||||
|
||||
function buildSuccessResult(
|
||||
stage: InvokeRequest["stage"],
|
||||
task: InvokeRequest["task"],
|
||||
outputText?: string,
|
||||
): HandoffMessage {
|
||||
// Pack the LLM output into selfTestReport / verificationResults so the
|
||||
// next stage can read it via the handoff payload.
|
||||
void outputText;
|
||||
switch (stage) {
|
||||
case "plan":
|
||||
return {
|
||||
|
||||
@@ -89,14 +89,14 @@ export async function runPipeline(opts: RunOptions): Promise<RunResult> {
|
||||
description: opts.requirements,
|
||||
workdir: process.cwd(),
|
||||
},
|
||||
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 30_000,
|
||||
timeoutMs: opts.config.agents[stage]?.timeoutMs ?? 600_000,
|
||||
structuredOutput: true,
|
||||
};
|
||||
|
||||
const retryResult = await withRetry(
|
||||
async () => transport.invoke(invokeReq, opts.signal),
|
||||
{
|
||||
maxRetries: opts.maxRetries ?? 3,
|
||||
maxRetries: opts.maxRetries ?? 1,
|
||||
...(opts.signal && { signal: opts.signal }),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user