/** * 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 (code === 'oauth_required' || code === 'oauth_expired' || code === 'provider_401') 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; }