Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
97 lines
12 KiB
JavaScript
97 lines
12 KiB
JavaScript
'use strict';
|
|
// OpenAI 호환 Chat Completions(도구 호출) 로 동작하는 파일 작업 에이전트.
|
|
// OpenAI · Groq · OpenRouter · Gemini(OpenAI 호환 엔드포인트) · 기타 호환 서버에서 공통으로 사용한다.
|
|
// runClaude 와 같은 결과 형태를 돌려준다: {ok, resultText, usage{input,output,cacheCreate,cacheRead,thinking,total}, costUsd, model, numTurns, durationMs}
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const MAX_TURNS = parseInt(process.env.OPENAI_ENGINE_MAX_TURNS || '60', 10);
|
|
const MAX_FILE = 2 * 1024 * 1024;
|
|
|
|
/**
|
|
* 사이트 폴더 안의 경로만 허용. v1.6.31 (검토 S15): 심볼릭 링크가 밖을 가리키는 경우도 막는다 — 존재하는 가장 깊은 조상의 실경로가 사이트 실경로 아래여야 한다.
|
|
*/
|
|
function safePath(siteDir, rel) {
|
|
const root = (() => { try { return fs.realpathSync(siteDir); } catch (_) { return path.resolve(siteDir); } })();
|
|
const p = path.resolve(root, String(rel || '').replace(/^\/+/, ''));
|
|
if (!p.startsWith(root + path.sep) && p !== root) throw new Error('사이트 폴더 밖 경로는 사용할 수 없습니다: ' + rel);
|
|
if (path.basename(p).startsWith('.') || p.split(path.sep).some((s) => s.startsWith('.') && s !== '.refs')) throw new Error('숨김 파일은 사용할 수 없습니다: ' + rel);
|
|
let probe = p; while (probe !== root && !fs.existsSync(probe)) probe = path.dirname(probe);
|
|
try { const real = fs.realpathSync(probe); if (!real.startsWith(root + path.sep) && real !== root) throw new Error('심볼릭 링크가 사이트 폴더 밖을 가리킵니다: ' + rel); } catch (e) { if (/심볼릭/.test(e.message)) throw e; }
|
|
return p;
|
|
}
|
|
// v1.6.31 (검토 S14): 요청 시간·응답 크기·누적 문맥·전체 실행 시간 상한
|
|
const REQ_TIMEOUT_MS = 180000; const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; const MAX_CONTEXT_BYTES = 6 * 1024 * 1024;
|
|
const MAX_JOB_MIN = () => { try { const v = parseInt(require('./config').get('ai.maxJobMinutes') || '45', 10); return Number.isFinite(v) && v > 0 ? v : 45; } catch (_) { return 45; } };
|
|
function listFiles(dir, base = '') { let out = []; let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return out; } for (const e of ents) { if (e.name.startsWith('.') && e.name !== '.refs') continue; const rel = base ? `${base}/${e.name}` : e.name; if (e.isDirectory()) out = out.concat(listFiles(path.join(dir, e.name), rel)); else out.push(rel); } return out.sort(); }
|
|
|
|
const TOOLS = {
|
|
list_files: { def: { type: 'function', function: { name: 'list_files', description: '사이트 폴더의 파일 목록을 돌려준다(상대 경로).', parameters: { type: 'object', properties: {}, additionalProperties: false } } },
|
|
run: (siteDir) => listFiles(siteDir).join('\n') || '(파일 없음)', progress: () => '파일 구조 확인 중', log: () => '파일 검색: **/*' },
|
|
read_file: { def: { type: 'function', function: { name: 'read_file', description: '파일 내용을 읽는다. 텍스트 파일만 지원한다.', parameters: { type: 'object', properties: { path: { type: 'string', description: '사이트 루트 기준 상대 경로' } }, required: ['path'], additionalProperties: false } } },
|
|
run: (siteDir, a) => { const p = safePath(siteDir, a.path); const st = fs.statSync(p); if (st.size > MAX_FILE) throw new Error('파일이 너무 큽니다'); return fs.readFileSync(p, 'utf8'); }, progress: (a) => `내용 확인 중: ${path.basename(a.path || '')}`, log: (a) => `파일 읽기: ${path.basename(a.path || '')}` },
|
|
write_file: { def: { type: 'function', function: { name: 'write_file', description: '파일을 통째로 쓴다(없으면 생성, 있으면 덮어씀). 하위 폴더는 자동 생성.', parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false } } },
|
|
run: (siteDir, a) => { const p = safePath(siteDir, a.path); fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o755 }); fs.writeFileSync(p, String(a.content), { mode: 0o644 }); return `written ${a.path} (${Buffer.byteLength(String(a.content))} bytes)`; }, progress: (a) => `파일 작성 중: ${path.basename(a.path || '')}`, log: (a) => `파일 작성: ${path.basename(a.path || '')} (${String(a.content || '').length.toLocaleString()}자)` },
|
|
edit_file: { def: { type: 'function', function: { name: 'edit_file', description: '파일에서 old_text 를 찾아 new_text 로 한 번 바꾼다(정확히 일치해야 함).', parameters: { type: 'object', properties: { path: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' } }, required: ['path', 'old_text', 'new_text'], additionalProperties: false } } },
|
|
run: (siteDir, a) => { const p = safePath(siteDir, a.path); const s = fs.readFileSync(p, 'utf8'); const i = s.indexOf(a.old_text); if (i < 0) throw new Error('old_text 를 찾지 못했습니다'); fs.writeFileSync(p, s.slice(0, i) + a.new_text + s.slice(i + a.old_text.length)); return `edited ${a.path}`; }, progress: (a) => `파일 수정 중: ${path.basename(a.path || '')}`, log: (a) => `파일 수정: ${path.basename(a.path || '')}` },
|
|
delete_file: { def: { type: 'function', function: { name: 'delete_file', description: '파일을 삭제한다.', parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false } } },
|
|
run: (siteDir, a) => { fs.unlinkSync(safePath(siteDir, a.path)); return `deleted ${a.path}`; }, progress: (a) => `파일 삭제 중: ${path.basename(a.path || '')}`, log: (a) => `파일 삭제: ${path.basename(a.path || '')}` },
|
|
};
|
|
const TOOLSETS = { files: ['list_files', 'read_file', 'write_file', 'edit_file', 'delete_file'], read: ['list_files', 'read_file'], none: [] };
|
|
|
|
/**
|
|
* @param {object} o siteDir, prompt, systemPrompt, toolset('files'|'read'|'none'), model, apiKey, baseUrl, provider, price{input,output}, onEvent, onStart
|
|
*/
|
|
async function runAgent(o) {
|
|
const started = Date.now(); const emit = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (_) {} };
|
|
const ac = new AbortController(); let cancelled = false;
|
|
if (o.onStart) o.onStart({ cancel: () => { cancelled = true; ac.abort(); } });
|
|
const base = String(o.baseUrl || '').replace(/\/$/, ''); if (!base) throw new Error('제공자 기본 URL 이 없습니다');
|
|
// v1.6.31 (요구 5, 검토 패치 2): 제공자 주소가 서버 내부(사설·루프백)를 가리키면 거절 — 관리자가 설정 service.internalAllow 에 적은 호스트:포트만 예외
|
|
await require('./netguard').assertExternalUrl(base + '/', { allowHttp: true });
|
|
const deadline = started + MAX_JOB_MIN() * 60000;
|
|
const headers = { 'Content-Type': 'application/json' }; if (o.apiKey) headers.Authorization = `Bearer ${o.apiKey}`;
|
|
if (o.provider === 'openrouter') { headers['HTTP-Referer'] = 'https://webmaker.ibit.kr'; headers['X-Title'] = 'WebMaker'; }
|
|
const toolNames = TOOLSETS[o.toolset || 'files'] || TOOLSETS.files; const tools = toolNames.map((n) => TOOLS[n].def);
|
|
const messages = [{ role: 'system', content: o.systemPrompt || '' }, { role: 'user', content: o.prompt }];
|
|
const usage = { input: 0, output: 0, cacheCreate: 0, cacheRead: 0, thinking: 0, total: 0 }; let calls = 0; let finalText = ''; let ok = false; let error = null;
|
|
const live = () => { usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; emit({ type: 'usage', live: true, usage: { ...usage }, calls }); };
|
|
emit({ type: 'log', level: 'info', text: `세션 시작 (모델: ${o.model}, 제공자: ${o.provider})` }); emit({ type: 'progress', text: 'AI 연결됨 · 작업을 구상하고 있습니다' });
|
|
try {
|
|
for (let turn = 0; turn < MAX_TURNS; turn++) {
|
|
if (cancelled) throw Object.assign(new Error('사용자가 작업을 중단했습니다'), { cancelled: true });
|
|
const body = { model: o.model, messages, temperature: 0.4 }; if (tools.length) { body.tools = tools; body.tool_choice = 'auto'; }
|
|
if (Date.now() > deadline) throw new Error(`최대 실행 시간 ${MAX_JOB_MIN()}분을 넘겨 중단했습니다`);
|
|
if (JSON.stringify(messages).length > MAX_CONTEXT_BYTES) throw new Error('대화 문맥이 너무 커져 중단했습니다 (작업을 나눠 다시 지시하세요)');
|
|
const reqAc = new AbortController(); const onAbort = () => reqAc.abort(); ac.signal.addEventListener('abort', onAbort, { once: true }); const reqTimer = setTimeout(() => reqAc.abort(), REQ_TIMEOUT_MS);
|
|
let res; try { res = await fetch(`${base}/chat/completions`, { method: 'POST', headers, body: JSON.stringify(body), signal: reqAc.signal, redirect: 'error' }); } finally { clearTimeout(reqTimer); ac.signal.removeEventListener('abort', onAbort); }
|
|
calls += 1;
|
|
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`API 오류 HTTP ${res.status}: ${t.slice(0, 300)}`); }
|
|
const rawText = await res.text(); if (rawText.length > MAX_RESPONSE_BYTES) throw new Error('응답이 너무 큽니다'); let j; try { j = JSON.parse(rawText); } catch (_) { throw new Error('응답이 JSON 이 아닙니다'); }
|
|
const u = j.usage || {}; usage.input += (u.prompt_tokens || 0) - ((u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0); usage.cacheRead += (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0; usage.output += u.completion_tokens || 0; usage.thinking += (u.completion_tokens_details && u.completion_tokens_details.reasoning_tokens) || 0; live();
|
|
const choice = (j.choices || [])[0]; if (!choice) throw new Error('응답에 choices 가 없습니다');
|
|
const msg = choice.message || {}; messages.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.tool_calls || undefined });
|
|
if (msg.content && String(msg.content).trim()) { finalText = String(msg.content).trim(); emit({ type: 'log', level: 'assistant', text: finalText.replace(/<brief>[\s\S]*?<\/brief>/g, '[Brief 갱신됨]') }); }
|
|
const tcs = msg.tool_calls || [];
|
|
if (!tcs.length) { ok = true; break; }
|
|
emit({ type: 'progress', text: '결과를 정리하고 있습니다' });
|
|
for (const tc of tcs) {
|
|
const name = tc.function && tc.function.name; let args = {}; try { args = JSON.parse(tc.function.arguments || '{}'); } catch (_) { args = {}; }
|
|
const T = TOOLS[name]; let result;
|
|
if (!T || !toolNames.includes(name)) result = `오류: 사용할 수 없는 도구 ${name}`;
|
|
else { emit({ type: 'progress', text: T.progress(args) }); emit({ type: 'log', level: 'tool', text: T.log(args) }); try { result = T.run(o.siteDir, args); } catch (e) { result = `오류: ${e.message}`; emit({ type: 'log', level: 'debug', text: `도구 오류: ${e.message}` }); } }
|
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: String(result).slice(0, 200000) });
|
|
}
|
|
if (turn === MAX_TURNS - 1) { error = `최대 반복 횟수(${MAX_TURNS})에 도달했습니다`; }
|
|
}
|
|
} catch (e) {
|
|
if (e.cancelled || e.name === 'AbortError' || cancelled) { usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead; throw Object.assign(new Error('사용자가 작업을 중단했습니다'), { cancelled: true, usage: { ...usage } }); }
|
|
error = e.message;
|
|
}
|
|
usage.total = usage.input + usage.output + usage.cacheCreate + usage.cacheRead;
|
|
const price = o.price || { input: 0, output: 0 };
|
|
const costUsd = (usage.input * (price.input || 0) + usage.cacheRead * (price.input || 0) * 0.5 + usage.output * (price.output || 0)) / 1e6;
|
|
return { engine: 'openai', errorText: error || null, /* v1.5.7: 실패 기록에 엔진 이름·원인을 남긴다 (server.js runCommand) */ ok: ok && !error, subtype: ok && !error ? 'success' : 'error', isError: !!error || !ok, resultText: error && !finalText ? error : finalText, error, usage, costUsd, numTurns: calls, durationMs: Date.now() - started, model: o.model, modelUsage: { [`${o.provider}:${o.model}`]: { inputTokens: usage.input, outputTokens: usage.output, cacheReadInputTokens: usage.cacheRead, cacheCreationInputTokens: 0, costUSD: costUsd } }, permissionDenials: [] };
|
|
}
|
|
module.exports = { runAgent, TOOLSETS };
|