Files
AriWeb-Suite/hal/ai/engine.mjs
T

161 lines
19 KiB
JavaScript
Raw Normal View History

/**
* hal/ai/engine.mjs — AI 채팅룸 엔진(0.3.0): 운영자 지시 → 응답할 모델 결정 → 모델 호출(도구 왕복) → 통합 방 전달 규칙 → 제안·승인·거부.
*
* 한 "턴"(runTurn)은 모델 하나가 방의 대화 기록을 받아 답하는 과정이다.
* 1) system prompt = HAL 역할·규칙 + 이 모델의 고유 정보(이름·역할·특성·지시) + 방 정보(참여자) + 운영자·화면 문맥 + 스킬 문서(hal/ai/skill.md).
* 2) 대화 기록을 공급자 블록 형식으로 편다(buildMessages). 다른 모델의 발언은 "[이름] …" 인 user 메시지로 보인다. 아직 결과가 없는 도구 호출(승인 대기)은 자리표시 결과를 넣는다.
* 3) 모델이 도구를 부르면: 읽기 도구는 바로 실행해 결과를 돌려주고 다시 부른다(최대 MAX_ITER 회). 쓰기·고위험 도구는 "제안"(ai_actions, status=proposed)으로 저장하고
* 대화에 제안 카드를 붙인 뒤 턴을 끝낸다 — 운영자가 승인(라우트가 재인증·3단계 확인을 적용)하면 execute() 가 딱 한 번 실행하고 결과를 도구 결과로 넣은 뒤 이어서 부른다. 거부하면 실행하지 않는다.
* 4) 통합 방에서 모델이 `@이름` 으로 다른 모델을 부르면 그 모델의 턴을 relayDepth=1 로 1회 돌린다. depth 1 의 답이 또 누군가를 불러도 자동으로 돌리지 않고 안내만 남긴다.
* 공급자 오류(인증·429·timeout·형식)는 모델 답으로 꾸미지 않고 status=failed 메시지로 남긴다.
* 비밀값은 자격(secretFor)으로만 흐르고 메시지·감사·오류에는 들어가지 않는다(redact·safeError).
* 턴은 프로세스 안에서 비동기로 돈다(HAL 재시작 뒤 진행 중 턴은 사라지며 "진행 중" 표시도 사라진다 — 거짓 진행 표시를 하지 않는다). 테스트는 drain() 으로 모든 턴을 기다린다.
*/
import { HttpError } from '../../lib/http.mjs';
import { audit } from '../../lib/audit.mjs';
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
import { newId } from '../../lib/crypto.mjs';
import { redact, safeError } from '../../lib/redact.mjs';
import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { toolByName, toolDefs, validateArgs, runTool, toolOutputText, SKILL_DOC, RISK_LABEL } from './tools.mjs';
import * as P from './providers.mjs';
const MAX_ITER = 8; // 한 턴에서 도구 왕복 최대 횟수(무한 반복 방지)
const MAX_TEXT = 20000; // 운영자 지시 최대 글자 수
const textOf = (blocks) => (blocks || []).filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
export function publicAction(r) { if (!r) return null; const tool = toolByName(r.tool); const args = parseJson(r.args_json, {}); return { id: r.id, roomId: r.room_id, messageId: r.message_id, connectionId: r.connection_id, tool: r.tool, args, risk: r.risk, riskLabel: RISK_LABEL[r.risk] || r.risk, description: tool ? tool.description : '(알 수 없는 도구)', identifiers: tool && tool.identifiers ? tool.identifiers(args) : [], status: r.status, proposedAt: r.proposed_at, decidedBy: r.decided_by, decidedAt: r.decided_at, result: parseJson(r.result_json, null), error: r.error, toolUseId: r.tool_use_id, relayDepth: r.relay_depth }; }
export function createEngine(d) {
const inflight = new Set(); const pendingByRoom = new Map();
const bump = (roomId, n) => pendingByRoom.set(roomId, Math.max(0, (pendingByRoom.get(roomId) || 0) + n));
const track = (p) => { inflight.add(p); p.finally(() => inflight.delete(p)); return p; };
R.ensureGroupRoom(d);
/** system prompt — 모델별 고유 정보와 방 규칙. 비밀 없음. */
function systemPrompt(room, conn, participants, { operator, context } = {}) {
const idn = conn.identity || {}; const others = participants.filter((p) => p.id !== conn.id);
const lines = [
`너는 Ariweb Suite 제어 평면 HAL 의 운영자 콘솔 AI 채팅룸에 연결된 모델 참여자다. 네 이름은 "${conn.name}" 이다(공급자 ${conn.providerLabel}, 모델 ${conn.model}).`,
`역할: ${idn.role || '(지정 없음)'}`, `특성: ${idn.traits || '(지정 없음)'}`, idn.instructions ? `추가 지시: ${idn.instructions}` : '',
room.kind === 'group' ? `방: 통합 방 — 연결된 모든 활성 모델이 함께 있다. 참여자: ${participants.map((p) => `"${p.name}"${p.id === conn.id ? '(나)' : ''}${p.identity && p.identity.role ? ` — ${p.identity.role}` : ''}`).join(', ')}. 운영자의 지시는 모두에게 전달되며, 운영자가 "@이름" 으로 지정한 모델만 답해야 할 때는 그 모델만 답한다(지금 네가 부름을 받았다). 다른 참여자에게 말을 넘기려면 "@이름" 을 쓴다(그 참여자는 자동으로 1회만 답한다). 자기 자신(@${conn.name})을 부르지 마라.${others.length ? '' : ' 지금은 다른 활성 참여자가 없다.'}` : `방: 개별 방 — 운영자와 1:1 대화. 다른 모델은 이 대화를 보지 않는다.`,
operator ? `운영자: ${operator.displayName || operator.login}(${operator.login}).` : '', context && (context.page || context.id) ? `운영자가 보고 있는 HAL 화면: ${context.page || ''}${context.id ? ` (${context.id})` : ''}.` : '',
conn.toolsSupported ? `도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다. 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).` : `도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
'한국어로 답하라. 사실·근거·확인하지 못한 것을 구분하라.', '', SKILL_DOC,
];
return lines.filter((l) => l !== '').join('\n');
}
/** 방의 기록 → 이 모델이 받을 공급자 블록 메시지. */
function buildMessages(room, conn) {
const rows = R.listMessages(d, room.id, { limit: 2000 }); const resolved = new Set();
for (const m of rows) if (m.role === 'tool' && m.connectionId === conn.id) for (const b of m.content) if (b.type === 'tool_result') resolved.add(b.toolUseId);
const out = [];
for (const m of rows) {
if (m.status === 'failed') continue;
if (m.role === 'user') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: room.kind === 'group' ? `[${m.author || '운영자'}] ${t}` : t }] }); continue; }
if (m.role === 'system') { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: `[HAL] ${t}` }] }); continue; }
if (m.role === 'tool') { if (m.connectionId === conn.id) out.push({ role: 'user', content: m.content.filter((b) => b.type === 'tool_result') }); continue; }
if (m.role === 'assistant') {
if (m.connectionId === conn.id) {
const blocks = m.content.filter((b) => b.type === 'text' || b.type === 'tool_use'); if (blocks.length) out.push({ role: 'assistant', content: blocks });
const open = blocks.filter((b) => b.type === 'tool_use' && !resolved.has(b.id)); if (open.length) out.push({ role: 'user', content: open.map((b) => ({ type: 'tool_result', toolUseId: b.id, name: b.name, ok: false, output: '(결과 없음 — 운영자 승인 대기 중이거나 실행되지 않았습니다)' })) });
} else { const t = textOf(m.content); if (t) out.push({ role: 'user', content: [{ type: 'text', text: `[${m.author || '다른 모델'}] ${t}` }] }); }
}
}
return out;
}
const actorFor = (operator, conn) => ({ operatorId: operator ? operator.id : null, label: `ai:${conn.id}` });
/** 모델 턴 하나. 끝나면 (통합 방) @전달 규칙을 적용한다. */
async function runTurn(room, conn, { relayDepth = 0, operator = null, context = null } = {}) {
bump(room.id, 1);
try {
const participants = C.active(d); const system = systemPrompt(room, conn, participants, { operator, context });
let secret; try { secret = await C.secretFor(d, conn); } catch (e) { fail(room, conn, relayDepth, e); return; }
const tools = conn.toolsSupported ? toolDefs(conn.provider === 'openai' ? 'openai' : 'anthropic') : [];
let finalText = ''; let iter = 0;
while (iter++ < MAX_ITER) {
let resp; try { resp = await P.chat(d, conn, secret, { system, messages: buildMessages(room, conn), tools }); } catch (e) { fail(room, conn, relayDepth, e); return; }
const asst = R.appendMessage(d, { roomId: room.id, role: 'assistant', connectionId: conn.id, author: conn.name, content: resp.blocks, relayDepth, usage: { ...resp.usage, model: resp.model || conn.model }, status: 'done' });
const uses = resp.blocks.filter((b) => b.type === 'tool_use');
if (!uses.length) { finalText = textOf(resp.blocks); break; }
const results = []; const proposals = [];
for (const u of uses) {
const tool = toolByName(u.name);
if (!tool) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `알 수 없는 도구: ${u.name}. HAL 이 제공하는 도구만 부를 수 있다(임의 명령·셸 실행 도구는 없다).` }); continue; }
const why = validateArgs(tool, u.input); if (why) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `인자 오류: ${why}` }); continue; }
if (tool.risk === 'read') { try { const out = await runTool(d, tool, u.input, actorFor(operator, conn)); results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: true, output: toolOutputText(out) }); } catch (e) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `도구 실행 실패(${e.code || 'error'}): ${safeError(e)}` }); } continue; }
const a = propose(room, conn, asst, u, tool, relayDepth, operator); proposals.push(a);
}
if (proposals.length) R.updateMessage(d, asst.id, { content: [...resp.blocks, ...proposals.map((a) => ({ type: 'proposal', actionId: a.id, tool: a.tool, args: a.args, risk: a.risk, riskLabel: a.riskLabel, identifiers: a.identifiers, description: a.description }))] });
if (results.length) R.appendMessage(d, { roomId: room.id, role: 'tool', connectionId: conn.id, author: conn.name, content: results, relayDepth });
if (proposals.length) return; // 승인 대기 — 승인·거부 전에는 모델을 다시 부르지 않는다
}
if (iter > MAX_ITER) R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${conn.name} 의 도구 왕복이 ${MAX_ITER}회를 넘어 이 턴을 멈췄습니다 — 필요하면 다시 지시하세요.` }], relayDepth });
if (room.kind === 'group' && finalText) {
const targets = R.mentionedParticipants(finalText, participants, conn.id);
if (targets.length) {
if (relayDepth === 0) { for (const t of targets) await runTurn(room, t, { relayDepth: 1, operator, context }); }
else R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${conn.name} 이(가) ${targets.map((t) => '@' + t.name).join(' ')} 을(를) 불렀지만 모델→모델 전달은 자동으로 1회만 이어집니다 — 추가 반응은 사용자 지시로 진행합니다.` }], relayDepth });
}
}
} finally { bump(room.id, -1); }
}
function fail(room, conn, relayDepth, e) {
const msg = safeError(e); const code = e && e.code ? e.code : 'provider_error';
R.appendMessage(d, { roomId: room.id, role: 'assistant', connectionId: conn.id, author: conn.name, content: [{ type: 'text', text: '' }], relayDepth, status: 'failed', error: `${code}: ${msg}` });
if (['oauth_required', 'oauth_expired', 'provider_401', 'cli_not_logged_in'].includes(code)) d.store.run('UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE \'reauth_required\' END, last_error=?, updated_at=? WHERE id=?', msg, nowIso(), conn.id);
else d.store.run('UPDATE ai_connections SET last_error=?, updated_at=? WHERE id=?', msg, nowIso(), conn.id);
audit(d.store, { actorType: 'system', actorId: `ai:${conn.id}`, action: 'ai.turn.failed', subjectType: 'ai_room', subjectId: room.id, outcome: 'failed', automatic: true, detail: { connection: conn.id, code, message: msg } });
}
function propose(room, conn, asst, use, tool, relayDepth, operator) {
const id = newId('aia_'); const args = use.input || {};
d.store.run('INSERT INTO ai_actions(id, room_id, message_id, connection_id, tool, args_json, risk, status, proposed_at, tool_use_id, relay_depth) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, room.id, asst.id, conn.id, tool.name, toJson(redact(args)), tool.risk, 'proposed', nowIso(), use.id, relayDepth);
audit(d.store, { actorType: 'system', actorId: `ai:${conn.id}`, action: 'ai.action.propose', subjectType: 'ai_action', subjectId: id, automatic: true, detail: { room: room.id, tool: tool.name, risk: tool.risk, args, operator: operator ? operator.id : null } });
return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id));
}
const startTurn = (room, conn, opts) => { const id = newId('turn_'); track(runTurn(room, conn, opts).catch((e) => d.log('[ai] turn error', safeError(e)))); return id; };
const engine = {
/** 모든 진행 중 턴이 끝날 때까지(테스트·종료용) */
async drain() { while (inflight.size) await Promise.allSettled([...inflight]); },
pending: (roomId) => pendingByRoom.get(roomId) || 0,
/** 운영자 지시 보내기 → 응답할 모델들의 턴을 시작한다. */
send(roomId, { text, operator, context = null }) {
const t = String(text || '').trim(); if (!t) throw new HttpError(400, 'empty_message', '지시 내용이 비어 있습니다'); if (t.length > MAX_TEXT) throw new HttpError(400, 'message_too_long', `지시는 ${MAX_TEXT}자까지입니다`);
const room = R.getRoom(d, roomId); if (!room) throw new HttpError(404, 'room_not_found', '방이 없습니다');
let responders;
if (room.kind === 'direct') { const conn = C.get(d, room.connection_id); if (!conn) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); if (conn.status !== 'active') throw new HttpError(409, 'connection_inactive', `연결 "${conn.name}" 이(가) ${conn.status} 상태라 보낼 수 없습니다`, { status: conn.status, lastError: conn.lastError }); responders = [conn]; }
else { const participants = C.active(d); if (!participants.length) throw new HttpError(409, 'no_active_connections', '활성 AI 연결이 없습니다 — AI 연동 화면에서 연결을 추가하세요'); const { addressed, unknown } = R.parseAddressing(t, participants); if (unknown.length) throw new HttpError(400, 'unknown_addressee', `모르는 이름입니다: ${unknown.map((n) => '@' + n).join(', ')}`, { unknown, participants: participants.map((p) => p.name) }); responders = addressed.length ? participants.filter((p) => addressed.includes(p.id)) : participants; }
const message = R.appendMessage(d, { roomId: room.id, role: 'user', author: operator ? operator.login : null, content: [{ type: 'text', text: t }], addressed: responders.map((c) => c.id), relayDepth: 0 });
const turns = responders.map((conn) => startTurn(room, conn, { relayDepth: 0, operator, context }));
return { message, responders: responders.map((c) => c.id), turns };
},
listActions({ roomId = null, status = null, limit = 200 } = {}) { const w = []; const p = []; if (roomId) { w.push('room_id=?'); p.push(roomId); } if (status) { w.push('status=?'); p.push(status); } return d.store.all(`SELECT * FROM ai_actions ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY proposed_at DESC LIMIT ?`, ...p, Math.min(1000, limit)).map(publicAction); },
getAction(id) { return publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id)); },
/** 승인 뒤 실행(라우트가 재인증·3단계 확인을 먼저 적용한다). 딱 한 번만 실행되며 결과를 도구 결과로 넣고 모델을 이어서 부른다. */
async execute(actionId, operator) {
const a = engine.getAction(actionId); if (!a) throw new HttpError(404, 'action_not_found', '제안이 없습니다'); if (a.status !== 'proposed') throw new HttpError(409, 'action_not_proposed', `이미 처리된 제안입니다(${a.status})`);
const tool = toolByName(a.tool); const conn = C.get(d, a.connectionId); const room = R.getRoom(d, a.roomId);
// 상태를 먼저 바꿔 두 번 실행되지 않게 한다(동시 승인 클릭)
const claimed = d.store.run('UPDATE ai_actions SET status=\'executing\', decided_by=?, decided_at=? WHERE id=? AND status=\'proposed\'', operator.id, nowIso(), actionId).changes; if (!claimed) throw new HttpError(409, 'action_not_proposed', '이미 처리된 제안입니다');
let result = null; let error = null;
try { if (!tool) throw new HttpError(400, 'unknown_tool', '알 수 없는 도구'); result = await runTool(d, tool, a.args, { operatorId: operator.id, label: `ai:${a.connectionId}` }); } catch (e) { error = `${e.code || 'error'}: ${safeError(e)}`; }
d.store.run('UPDATE ai_actions SET status=?, result_json=?, error=? WHERE id=?', error ? 'failed' : 'executed', result ? toJson(result) : null, error, actionId);
audit(d.store, { actorType: 'operator', actorId: operator.id, action: 'ai.action.execute', subjectType: 'ai_action', subjectId: actionId, outcome: error ? 'failed' : 'ok', detail: { tool: a.tool, risk: a.risk, args: a.args, room: a.roomId, connection: a.connectionId, error } });
if (room && conn) { R.appendMessage(d, { roomId: room.id, role: 'tool', connectionId: conn.id, author: conn.name, content: [{ type: 'tool_result', toolUseId: a.toolUseId, name: a.tool, ok: !error, output: error ? `운영자가 승인했지만 실행에 실패했습니다 — ${error}` : toolOutputText(result), actionId }], relayDepth: a.relayDepth || 0 }); if (conn.status === 'active') startTurn(room, conn, { relayDepth: a.relayDepth || 0, operator }); }
return engine.getAction(actionId);
},
reject(actionId, operator, reason = '') {
const a = engine.getAction(actionId); if (!a) throw new HttpError(404, 'action_not_found', '제안이 없습니다'); if (a.status !== 'proposed') throw new HttpError(409, 'action_not_proposed', `이미 처리된 제안입니다(${a.status})`);
d.store.run('UPDATE ai_actions SET status=\'rejected\', decided_by=?, decided_at=?, error=? WHERE id=?', operator.id, nowIso(), reason ? String(reason).slice(0, 500) : null, actionId);
audit(d.store, { actorType: 'operator', actorId: operator.id, action: 'ai.action.reject', subjectType: 'ai_action', subjectId: actionId, outcome: 'denied', detail: { tool: a.tool, risk: a.risk, reason } });
const conn = C.get(d, a.connectionId); if (conn) R.appendMessage(d, { roomId: a.roomId, role: 'tool', connectionId: conn.id, author: conn.name, content: [{ type: 'tool_result', toolUseId: a.toolUseId, name: a.tool, ok: false, output: `운영자가 이 제안을 거부했습니다${reason ? ` — ${String(reason).slice(0, 500)}` : ''}. 실행되지 않았습니다.`, actionId }], relayDepth: a.relayDepth || 0 });
return engine.getAction(actionId); // 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)
},
systemPrompt,
};
return engine;
}