HAL 0.3.2: AI 연동 편의성 + 도구 기본 설정·승인 요청 — 공급자 프리셋(Claude·ChatGPT·Gemini·OpenRouter + 기타 OpenAI 호환, Gemini 자체 API 어댑터), 사전 준비 안내, OAuth 저장 직후 로그인 창 자동(완료 페이지 postMessage·닫기, Gemini Google 프리셋, OpenRouter PKCE 키 발급), CLI 로그인용 텍스트 셸(허용 명령만 PTY, 재인증, 입력 미저장, 미로그인 시 자동 열기), 연결별 도구 정책(기본 전부)·로컬 CLI 에 HAL MCP 설정 자동 주입·MCP 설정 스니펫, MCP 쓰기·고위험 호출은 승인 요청(제안)으로 + hal_action_status/hal_actions_pending, 개발 지시 v0.3.2(Oz)·docs/HAL-AI.md §7·VERSION_HISTORY 0.3.2 — test/hal-ai-032.test.mjs RED 0/6 → GREEN 6/6, 전체 27파일 189건 통과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+21
-6
@@ -19,13 +19,14 @@ 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 { toolFormat } from './providers.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 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, source: r.source || 'chat', 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();
|
||||
@@ -41,7 +42,7 @@ export function createEngine(d) {
|
||||
`역할: ${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 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
|
||||
conn.toolsSupported && (conn.tools?.enabled || []).length ? (conn.toolsVia === 'mcp' ? `도구: HAL MCP 서버(hal)가 붙어 있다 — mcp__hal 도구로 HAL 을 조회할 수 있고, 쓰기·고위험 도구는 승인 요청(제안)으로 올라가며 hal_action_status 로 결과를 본다.` : `도구: 아래 스킬 문서의 규칙대로 HAL 도구를 쓸 수 있다(이 연결에 켜진 도구 ${conn.tools.enabled.length}개). 읽기 도구는 즉시 실행되고, 쓰기·고위험 도구는 운영자 승인 전에는 실행되지 않는다(제안으로 멈춤).`) : `도구: 이 연결에는 HAL 도구가 없다(대화·분석 전용). 운영자가 준 정보로만 답하고 상태를 추측하지 마라.`,
|
||||
'한국어로 답하라. 사실·근거·확인하지 못한 것을 구분하라.', '', SKILL_DOC,
|
||||
];
|
||||
return lines.filter((l) => l !== '').join('\n');
|
||||
@@ -73,16 +74,16 @@ export function createEngine(d) {
|
||||
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') : [];
|
||||
const enabled = conn.toolsSupported ? (conn.tools?.enabled || []) : []; const tools = enabled.length && conn.toolsVia !== 'mcp' ? toolDefs(toolFormat(conn.provider), enabled) : []; // [0.3.2] 연결별 도구 정책
|
||||
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; }
|
||||
let resp; try { resp = await P.chat(d, conn, secret, { system, messages: buildMessages(room, conn), tools, mcp: conn.toolsVia === 'mcp' && enabled.length > 0 }); } 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);
|
||||
const tool = toolByName(u.name); if (tool && !enabled.includes(tool.name)) { results.push({ type: 'tool_result', toolUseId: u.id, name: u.name, ok: false, output: `이 연결에서 꺼진 도구입니다: ${u.name}(도구 정책 ${conn.tools?.preset || 'all'})` }); continue; }
|
||||
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; }
|
||||
@@ -115,6 +116,18 @@ export function createEngine(d) {
|
||||
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));
|
||||
}
|
||||
/** [0.3.2] 채팅 밖 출처(MCP 클라이언트 등)의 승인 요청: 통합 방에 HAL 안내 + 제안 카드로 올린다. 인자 검증은 여기서(권한 아님). */
|
||||
function proposeExternal({ source = 'mcp', tool: name, args = {}, label = null }) {
|
||||
const tool = toolByName(name); if (!tool) throw new HttpError(404, 'unknown_tool', `알 수 없는 도구: ${name}`); if (tool.risk === 'read') throw new HttpError(400, 'not_approvable', '읽기 도구는 승인 대상이 아닙니다');
|
||||
const why = validateArgs(tool, args); if (why) throw new HttpError(400, 'bad_tool_args', why);
|
||||
R.ensureGroupRoom(d); const id = newId('aia_'); const src = String(source).slice(0, 40);
|
||||
const msg = R.appendMessage(d, { roomId: R.GROUP_ROOM, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${label || `외부 클라이언트(${src})`}가 ${RISK_LABEL[tool.risk]} 도구 ${tool.name} 실행 승인을 요청했습니다.` }], relayDepth: 0 });
|
||||
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, source) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)', id, R.GROUP_ROOM, msg.id, `ext:${src}`, tool.name, toJson(redact(args)), tool.risk, 'proposed', nowIso(), null, 0, src);
|
||||
const a = publicAction(d.store.get('SELECT * FROM ai_actions WHERE id=?', id));
|
||||
R.updateMessage(d, msg.id, { content: [...msg.content, { type: 'proposal', actionId: a.id, tool: a.tool, args: a.args, risk: a.risk, riskLabel: a.riskLabel, identifiers: a.identifiers, description: a.description, source: src }] });
|
||||
audit(d.store, { actorType: 'system', actorId: `ext:${src}`, action: 'ai.action.propose', subjectType: 'ai_action', subjectId: id, automatic: true, detail: { source: src, tool: tool.name, risk: tool.risk, args } });
|
||||
return a;
|
||||
}
|
||||
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 = {
|
||||
@@ -145,6 +158,7 @@ export function createEngine(d) {
|
||||
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 }); }
|
||||
else if (room) R.appendMessage(d, { roomId: room.id, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${a.source} 의 승인 요청 ${a.tool} 을(를) ${operator.login} 이(가) 승인해 실행했습니다${error ? ' — 실패' : ''}.` }, { type: 'tool_result', toolUseId: null, name: a.tool, ok: !error, output: error ? `실행 실패 — ${error}` : toolOutputText(result), actionId }], relayDepth: 0 }); // [0.3.2] 외부 출처: 통합 방에 결과 안내(이어 부를 모델 없음)
|
||||
return engine.getAction(actionId);
|
||||
},
|
||||
reject(actionId, operator, reason = '') {
|
||||
@@ -152,9 +166,10 @@ export function createEngine(d) {
|
||||
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 });
|
||||
else R.appendMessage(d, { roomId: a.roomId, role: 'system', author: 'HAL', content: [{ type: 'text', text: `${a.source} 의 승인 요청 ${a.tool} 을(를) ${operator.login} 이(가) 거부했습니다${reason ? ` — ${String(reason).slice(0, 300)}` : ''}. 실행되지 않았습니다.` }], relayDepth: 0 });
|
||||
return engine.getAction(actionId); // 거부는 모델을 자동으로 다시 부르지 않는다(운영자가 다음 지시를 준다)
|
||||
},
|
||||
systemPrompt,
|
||||
systemPrompt, proposeExternal,
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user