124 lines
24 KiB
JavaScript
124 lines
24 KiB
JavaScript
/**
|
|
* hal/ai/tools.mjs — AI 모델에게 주는 HAL 도구(스킬) 레지스트리(0.3.0, 개발 지시 v0.3.0 / v0.1.4 §41.4).
|
|
*
|
|
* 원칙
|
|
* - 도구는 전부 HAL 의 **형식화된 작업**이다. 임의 셸 명령·파일 경로·네트워크 대상을 받는 도구는 없다.
|
|
* - 위험 등급(risk): `read`(조회 — 바로 실행) · `write`(상태를 바꾸지만 되돌리기 쉬움 — 운영자 승인 + 재인증) · `high`(서버 격리·삭제, 모듈 배포·되돌리기·맞추기,
|
|
* 릴리스 승인, 작업 되돌리기, 설정 변경 — 운영자 승인 + 재인증 + 3단계 확인). 엔진(hal/ai/engine.mjs)은 read 만 즉시 실행하고 나머지는 "제안"으로 멈춘다.
|
|
* - 각 run() 은 hal/app.mjs 의 운영자 API 와 **같은 core/jobs 함수**를 같은 인자로 부른다(권한·감사 규칙 우회 없음). 승인 뒤 실행할 때 actor 는 운영자이고, 감사에는 AI 연결·제안 id 가 붙는다.
|
|
* - 인자는 JSON schema 비슷한 선언(schema)으로 검증한다(모르는 키·형식 오류는 실행하지 않고 모델에게 오류로 돌려준다). 모델 응답의 인자를 권한으로 믿지 않는다.
|
|
*/
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { describe } from '../../lib/jobs.mjs';
|
|
import { listAudit } from '../../lib/audit.mjs';
|
|
import { redact } from '../../lib/redact.mjs';
|
|
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
|
|
import { newId } from '../../lib/crypto.mjs';
|
|
import { readFileSync } from 'node:fs';
|
|
import * as core from '../core.mjs';
|
|
import * as emergency from '../emergency.mjs';
|
|
import * as dr from '../dr.mjs';
|
|
import * as mailsvc from '../mailsvc.mjs';
|
|
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
|
import { overviewLocal, changelog } from '../ui.mjs';
|
|
|
|
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
|
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
|
|
|
|
const S = (props, required = []) => ({ type: 'object', properties: props, required, additionalProperties: false });
|
|
const str = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
const num = (description) => ({ type: 'number', description });
|
|
const bool = (description) => ({ type: 'boolean', description });
|
|
const arr = (description, items = { type: 'string' }) => ({ type: 'array', description, items });
|
|
const SERVER_ID = str('서버 id(srv_…) — hal_servers_list 로 확인');
|
|
const jobView = (j) => j ? { ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type } : null;
|
|
const notFound = (code, msg) => new HttpError(404, code, msg);
|
|
|
|
/** 도구 목록. 순서는 모델에게 보이는 순서(읽기 → 쓰기 → 고위험). */
|
|
export const TOOLS = [
|
|
// ---------------- 읽기(read)
|
|
{ name: 'hal_skill', risk: 'read', description: 'HAL 운영 스킬 문서(상태·작업·릴리스·오류 코드의 뜻과 규칙)를 돌려준다.', schema: S({}), run: () => ({ skill: SKILL_DOC }) },
|
|
{ name: 'hal_health', risk: 'read', description: 'HAL 자체 건강(ok·version·db·jobs·bootstrap 상태). 비밀 없음.', schema: S({}), run: (d) => core.selfHealth(d) },
|
|
{ name: 'hal_overview', risk: 'read', description: '운영 현황 요약: 구성요소 상태, 서버 수(offline·pending·maintenance·diskWarning), 진행 작업, 열린 사건, 목표·실제 버전 차이(드리프트), DR 준비도, 긴급 복구 세션.', schema: S({}), run: (d) => overviewLocal(d) },
|
|
{ name: 'hal_servers_list', risk: 'read', description: '관리 서버 목록(상태·역할·위치·Agent·용량·지표·모듈 목표/실제 버전).', schema: S({ role: str('역할로 거르기(web·mail·backup·control)', { enum: ['web', 'mail', 'backup', 'control'] }) }), run: (d, a) => ({ servers: core.listServers(d, { role: a.role || null }) }) },
|
|
{ name: 'hal_server_get', risk: 'read', description: '서버 하나의 상세와 최근 작업(최대 50건).', schema: S({ serverId: SERVER_ID }, ['serverId']), run: (d, a) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(jobView) }; } },
|
|
{ name: 'hal_server_metrics', risk: 'read', description: '서버 지표 표본(cpu·메모리·디스크·inode·네트워크). 기본 최근 24시간, 최대 2000개.', schema: S({ serverId: SERVER_ID, hours: num('조회 범위(시간, 기본 24, 최대 2160)'), limit: num('최대 표본 수(기본 500, 최대 2000)') }, ['serverId']), run: (d, a) => ({ samples: core.metrics(d, a.serverId, { since: new Date(Date.now() - Math.min(2160, a.hours || 24) * 3600000).toISOString(), limit: Math.min(2000, a.limit || 500) }) }) },
|
|
{ name: 'hal_releases_list', risk: 'read', description: '릴리스 등록부(status registered/approved, mainCommit, artifact sha256, checks)와 실행 중 버전·배포 순서·모듈 배치 상태.', schema: S({}), run: (d) => ({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }) },
|
|
{ name: 'hal_jobs_list', risk: 'read', description: 'HAL 작업 목록. status 는 쉼표 구분(queued,running,awaiting_external,needs_user,failed …).', schema: S({ status: str('상태 필터(쉼표 구분)'), kind: str('종류 필터(예: module.deploy)'), limit: num('최대 건수(기본 100, 최대 500)') }), run: (d, a) => ({ jobs: d.jobs.list({ status: a.status ? String(a.status).split(',').map((x) => x.trim()).filter(Boolean) : null, kind: a.kind || null, limit: Math.min(500, a.limit || 100) }).map(jobView) }) },
|
|
{ name: 'hal_job_get', risk: 'read', description: '작업 하나의 단계·기록·결과·자식 요약·Agent 작업.', schema: S({ jobId: str('작업 id(j_…)') }, ['jobId']), run: (d, a) => { const j = d.jobs.get(a.jobId); if (!j) throw notFound('job_not_found', '작업이 없습니다'); return { job: { ...jobView(j), result: redact(j.result), log: j.log }, children: d.jobs.childrenSummary(j.id), agentJobs: d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id) }; } },
|
|
{ name: 'hal_incidents_list', risk: 'read', description: '사건 목록(열린 것 먼저, 최대 200)과 최근 알림.', schema: S({ openOnly: bool('열린 사건만') }), run: (d, a) => ({ incidents: d.store.all(`SELECT * FROM incidents ${a.openOnly ? 'WHERE resolved_at IS NULL ' : ''}ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200`).map((i) => ({ ...i, notes: parseJson(i.notes_json, []) })) }) },
|
|
{ name: 'hal_audit_list', risk: 'read', description: '감사 기록 조회(행동 접두어·대상·기간). 비밀값은 [redacted].', schema: S({ action: str('행동 접두어(예: server., deploy., ai.)'), subjectType: str('대상 종류'), subjectId: str('대상 id'), since: str('ISO 시각 이후'), limit: num('최대 건수(기본 100, 최대 500)') }), run: (d, a) => ({ entries: listAudit(d.store, { action: a.action, subjectType: a.subjectType, subjectId: a.subjectId, since: a.since, limit: Math.min(500, a.limit || 100) }) }) },
|
|
{ name: 'hal_mail_services_list', risk: 'read', description: 'HAL 관리형 메일 서비스 등록부 readback(중복·비밀 누출 검사 포함, 값 없음).', schema: S({}), run: (d) => mailsvc.readback(d) },
|
|
{ name: 'hal_emergency_list', risk: 'read', description: '긴급 복구 세션 목록과 허용 범위·작업 종류.', schema: S({}), run: (d) => ({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS }) },
|
|
{ name: 'hal_dr_status', risk: 'read', description: '재해복구 준비도·스냅샷·복원·훈련 목록.', schema: S({}), run: (d) => ({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50') }) },
|
|
{ name: 'hal_action_status', risk: 'read', description: '승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.', schema: S({ actionId: str('제안 id(aia_…)') }, ['actionId']), run: (d, a) => { const x = d.ai && d.ai.getAction(a.actionId); if (!x) throw notFound('action_not_found', '제안이 없습니다'); return { action: x }; } },
|
|
{ name: 'hal_actions_pending', risk: 'read', description: '운영자 승인을 기다리는 제안 목록(모든 출처).', schema: S({}), run: (d) => ({ actions: d.ai ? d.ai.listActions({ status: 'proposed', limit: 100 }) : [] }) },
|
|
{ name: 'hal_changelog', risk: 'read', description: 'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).', schema: S({ version: str('버전(예: 0.3.0)') }), run: (d, a) => { const c = changelog(); return a.version ? { version: a.version, text: c[a.version] || null } : { versions: Object.keys(c) }; } },
|
|
// ---------------- 쓰기(write): 승인 + 재인증
|
|
{ name: 'hal_server_create', risk: 'write', description: '관리 서버를 등록(pending)한다. 등록 토큰 발급·Agent 설치·승인은 운영자가 콘솔에서 한다.', schema: S({ name: str('서버 이름'), role: str('역할', { enum: core.SERVER_ROLES }), location: str('위치') }, ['name', 'role']), run: (d, a, actor) => ({ server: core.publicServer(d, core.createServer(d, { name: a.name, role: a.role, location: a.location || '', by: actor.operatorId })) }) },
|
|
{ name: 'hal_server_approve', risk: 'write', description: 'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.', schema: S({ serverId: SERVER_ID }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => ({ server: core.publicServer(d, core.approveServer(d, a.serverId, actor.operatorId)) }) },
|
|
{ name: 'hal_server_maintenance', risk: 'write', description: '서버 유지보수 시작(신규 배치 중지) 또는 종료(end=true).', schema: S({ serverId: SERVER_ID, end: bool('true 면 유지보수 종료'), note: str('유지보수 메모'), acceptsNew: bool('종료 때 신규 배치 허용(기본 true)') }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { if (!core.getServer(d, a.serverId)) throw notFound('server_not_found', '서버가 없습니다'); const s = core.updateServer(d, a.serverId, a.end ? { status: 'ready', accepts_new: a.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: a.note || '' }, actor.operatorId); return { server: core.publicServer(d, s) }; } },
|
|
{ name: 'hal_incident_open', risk: 'write', description: '사건을 등록한다(같은 key 의 열린 사건이 있으면 병합).', schema: S({ key: str('사건 key(같은 원인 묶음)'), title: str('제목'), severity: str('심각도', { enum: ['info', 'warning', 'critical'] }), component: str('구성요소'), customerImpact: str('고객 영향(없으면 생략)') }, ['key', 'title']), run: (d, a, actor) => { const open = d.store.get('SELECT id FROM incidents WHERE key=? AND resolved_at IS NULL', a.key); if (open) { d.store.run('UPDATE incidents SET severity=?, title=?, customer_impact=?, updated_at=? WHERE id=?', a.severity || 'warning', a.title, a.customerImpact || null, nowIso(), open.id); return { id: open.id, merged: true }; } const id = newId('inc_'); d.store.run('INSERT INTO incidents(id, key, severity, title, component, customer_impact, started_at, assignee, public, public_note, notes_json, updated_at) VALUES(?,?,?,?,?,?,?,?,0,NULL,\'[]\',?)', id, a.key, a.severity || 'warning', a.title, a.component || null, a.customerImpact || null, nowIso(), actor.operatorId, nowIso()); return { id, merged: false }; } },
|
|
{ name: 'hal_incident_resolve', risk: 'write', description: '사건을 종료한다. 실제 사용자 경로 확인(verifiedUserPath) 없이는 거부된다(§31.2).', schema: S({ incidentId: str('사건 id(inc_…)'), verifiedUserPath: str('실제 확인한 사용자 경로(예: https://… 200)'), note: str('메모') }, ['incidentId', 'verifiedUserPath']), identifiers: (a) => [a.incidentId], run: (d, a, actor) => { const inc = d.store.get('SELECT * FROM incidents WHERE id=?', a.incidentId); if (!inc) throw notFound('not_found', '사건이 없습니다'); if (!a.verifiedUserPath) throw new HttpError(400, 'verify_required', '실제 사용자 경로 확인 뒤에 종료할 수 있습니다(§31.2)'); const notes = parseJson(inc.notes_json, []); notes.push({ at: nowIso(), by: actor.operatorId, note: a.note || `사용자 경로 확인: ${a.verifiedUserPath}` }); d.store.run('UPDATE incidents SET notes_json=?, resolved_at=?, verified_at=?, updated_at=? WHERE id=?', toJson(notes), nowIso(), nowIso(), nowIso(), inc.id); return { id: inc.id, resolved: true }; } },
|
|
{ name: 'hal_job_control', risk: 'write', description: '작업을 취소·재시도·재개·일시 중지한다(되돌리기는 hal_job_revert).', schema: S({ jobId: str('작업 id'), op: str('조작', { enum: ['cancel', 'retry', 'resume', 'pause'] }) }, ['jobId', 'op']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs[a.op](a.jobId, actor.operatorId)) }) },
|
|
{ name: 'hal_module_health', risk: 'write', description: '모듈 건강 확인 작업(module.health)을 큐에 넣는다(대상 서버 생략 시 전체).', schema: S({ module: str('모듈 이름', { enum: Object.keys(MODULES) }), serverIds: arr('대상 서버 id 목록') }, ['module']), run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.health', subject: { module: a.module, serverIds: a.serverIds || null }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 건강 확인` }).job) }) },
|
|
{ name: 'hal_dr_snapshot', risk: 'write', description: 'HAL 재해복구 스냅샷을 지금 만든다(manual).', schema: S({}), run: (d, a, actor) => ({ snapshot: dr.snapshot(d, { kind: 'manual', by: actor.operatorId }) }) },
|
|
// ---------------- 고위험(high): 승인 + 재인증 + 3단계 확인
|
|
{ name: 'hal_server_isolate', risk: 'high', description: '서버를 격리(isolated)한다 — 작업 배정이 멈춘다. 되돌리려면 운영자가 상태를 바꿔야 한다.', schema: S({ serverId: SERVER_ID, reason: str('격리 사유') }, ['serverId', 'reason']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { if (!core.getServer(d, a.serverId)) throw notFound('server_not_found', '서버가 없습니다'); return { server: core.publicServer(d, core.updateServer(d, a.serverId, { status: 'isolated', isolated_reason: a.reason }, actor.operatorId)) }; } },
|
|
{ name: 'hal_server_delete', risk: 'high', description: '서버를 삭제하고 Agent 를 해지한다. 서비스가 배치된 서버는 force 없이는 거부된다(먼저 이전).', schema: S({ serverId: SERVER_ID, force: bool('배치된 서비스가 있어도 삭제') }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => { const s = core.getServer(d, a.serverId); if (!s) throw notFound('server_not_found', '서버가 없습니다'); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !a.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, actor.operatorId); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); return { deleted: s.id }; } },
|
|
{ name: 'hal_module_deploy', risk: 'high', description: '승인된 릴리스의 모듈을 대상 서버에 배포하는 작업(module.deploy)을 큐에 넣는다. HAL 먼저 규칙·릴리스 승인·부트스트랩 전제는 작업 precheck 가 검사한다.', schema: S({ module: str('모듈 이름(HAL 은 배포 대상 아님)', { enum: DEPLOYABLE_MODULES }), version: str('릴리스 버전'), serverIds: arr('대상 서버 id 목록'), nonce: str('멱등 구분값(같은 배포를 다시 넣을 때)') }, ['module', 'version', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => { const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: a.module, version: a.version, serverIds: a.serverIds || [] }, idempotencyKey: `module.deploy:${a.module}:${a.version}:${(a.serverIds || []).slice().sort().join(',')}:${a.nonce || ''}`, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} ${a.version} 배포` }); return { job: jobView(r.job), created: r.created }; } },
|
|
{ name: 'hal_module_rollback', risk: 'high', description: '모듈을 이전 버전(toVersion 생략 시 직전 목표)으로 되돌리는 작업(module.rollback)을 큐에 넣는다.', schema: S({ module: str('모듈 이름', { enum: DEPLOYABLE_MODULES }), toVersion: str('되돌릴 버전'), serverIds: arr('대상 서버 id 목록') }, ['module', 'serverIds']), identifiers: (a) => a.serverIds || [], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.rollback', subject: { module: a.module, toVersion: a.toVersion || null, serverIds: a.serverIds || [] }, createdBy: actor.operatorId, actorType: 'operator', subjectType: 'module', subjectId: a.module, label: `${a.module} 되돌리기` }).job) }) },
|
|
{ name: 'hal_module_reconcile', risk: 'high', description: '목표 버전과 실제 버전이 다른 대상만 다시 설치하는 작업(module.reconcile)을 큐에 넣는다.', schema: S({ module: str('모듈 이름(생략 시 전체)', { enum: Object.keys(MODULES) }) }), identifiers: (a) => [a.module || 'all'], run: (d, a, actor) => ({ job: jobView(d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: a.module || null }, createdBy: actor.operatorId, actorType: 'operator', label: '목표·실제 상태 맞추기' }).job) }) },
|
|
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
|
|
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
|
|
{ name: 'hal_config_set', risk: 'high', description: 'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.', schema: S({ publicUrl: str('HAL 공개 주소'), portalUrl: str('포털 내부 주소'), offlineAfterMs: num('heartbeat 없음 → offline 판정(ms)'), metricsRetentionDays: num('지표 보존 일수(최소 90)') }), identifiers: () => ['hal'], run: (d, a, actor) => { const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) if (a[k] !== undefined) patch[k] = a[k]; if (!Object.keys(patch).length) throw new HttpError(400, 'nothing_to_set', '바꿀 값이 없습니다'); d.config.set(patch, actor.operatorId); return { updated: Object.keys(patch) }; } },
|
|
];
|
|
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
|
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
|
|
export const TOOL_PRESETS = [
|
|
{ id: 'all', label: '전부(기본값) — 읽기 즉시, 쓰기·고위험은 승인', tools: TOOLS.map((t) => t.name) },
|
|
{ id: 'read_write', label: '읽기 + 쓰기(고위험 제외)', tools: TOOLS.filter((t) => t.risk !== 'high').map((t) => t.name) },
|
|
{ id: 'read_only', label: '읽기만', tools: TOOLS.filter((t) => t.risk === 'read').map((t) => t.name) },
|
|
{ id: 'none', label: '도구 없음(대화만)', tools: [] },
|
|
];
|
|
export const presetTools = (id) => (TOOL_PRESETS.find((p) => p.id === id) || TOOL_PRESETS[0]).tools;
|
|
export const toolByName = (name) => BY_NAME.get(String(name || '')) || null;
|
|
export const RISK_LABEL = { read: '읽기', write: '쓰기(승인·재인증)', high: '고위험(승인·재인증·3단계 확인)' };
|
|
|
|
/** 공급자별 도구 선언. anthropic: {name, description, input_schema} / openai: {type:'function', function:{name, description, parameters}} / mcp: {name, description, inputSchema} */
|
|
export function toolDefs(format = 'anthropic', only = null) {
|
|
const list = only ? TOOLS.filter((t) => only.includes(t.name)) : TOOLS;
|
|
return list.map((t) => { const description = `[${RISK_LABEL[t.risk]}] ${t.description}`; if (format === 'openai') return { type: 'function', function: { name: t.name, description, parameters: t.schema } }; if (format === 'mcp') return { name: t.name, description, inputSchema: t.schema }; if (format === 'gemini') return { name: t.name, description, parameters: geminiSchema(t.schema) }; return { name: t.name, description, input_schema: t.schema }; });
|
|
}
|
|
/** Gemini 선언용 스키마 변환(순환 import 를 피해 여기 둔다) */
|
|
function geminiSchema(sc) { if (!sc || typeof sc !== 'object') return sc; const out = {}; if (sc.type) out.type = String(sc.type).toUpperCase(); if (sc.description) out.description = sc.description; if (sc.enum) out.enum = sc.enum; if (sc.required && sc.required.length) out.required = sc.required; if (sc.properties) { out.properties = {}; for (const [k, v] of Object.entries(sc.properties)) out.properties[k] = geminiSchema(v); } if (sc.items) out.items = geminiSchema(sc.items); return out; }
|
|
/** 공개용 목록(화면·API) */
|
|
export const publicTools = () => TOOLS.map((t) => ({ name: t.name, risk: t.risk, riskLabel: RISK_LABEL[t.risk], description: t.description, schema: t.schema }));
|
|
|
|
/**
|
|
* 인자 검증 — 선언(schema)과 다르면 이유 문자열을 돌려준다(없으면 null). 모델이 보낸 인자는 신뢰하지 않는다.
|
|
* - 객체여야 하고 모르는 키는 거부, required 누락 거부, 형식(string/number/boolean/array)·enum 검사. 문자열은 4000자, 배열은 200개까지.
|
|
*/
|
|
export function validateArgs(tool, args) {
|
|
if (args == null) args = {}; if (typeof args !== 'object' || Array.isArray(args)) return '인자는 객체여야 합니다';
|
|
const sc = tool.schema; const props = sc.properties || {};
|
|
for (const k of Object.keys(args)) if (!props[k]) return `모르는 인자: ${k}`;
|
|
for (const k of sc.required || []) if (args[k] === undefined || args[k] === null || args[k] === '') return `필수 인자 없음: ${k}`;
|
|
for (const [k, v] of Object.entries(args)) {
|
|
if (v === undefined || v === null) continue; const p = props[k];
|
|
if (p.type === 'string') { if (typeof v !== 'string') return `${k} 는 문자열이어야 합니다`; if (v.length > 4000) return `${k} 가 너무 깁니다`; if (p.enum && !p.enum.includes(v)) return `${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`; }
|
|
else if (p.type === 'number') { if (typeof v !== 'number' || !Number.isFinite(v)) return `${k} 는 숫자여야 합니다`; }
|
|
else if (p.type === 'boolean') { if (typeof v !== 'boolean') return `${k} 는 true/false 여야 합니다`; }
|
|
else if (p.type === 'array') { if (!Array.isArray(v)) return `${k} 는 배열이어야 합니다`; if (v.length > 200) return `${k} 항목이 너무 많습니다`; if (p.items?.type === 'string' && !v.every((x) => typeof x === 'string' && x.length <= 400)) return `${k} 항목은 문자열이어야 합니다`; }
|
|
}
|
|
return null;
|
|
}
|
|
/** 도구 실행(검증 뒤). 결과는 redact 를 거친다. actor = { operatorId, label } */
|
|
export async function runTool(d, tool, args, actor) {
|
|
const why = validateArgs(tool, args); if (why) throw new HttpError(400, 'bad_tool_args', why);
|
|
const out = await tool.run(d, args || {}, actor);
|
|
return redact(out === undefined ? { ok: true } : out);
|
|
}
|
|
/** 도구 출력을 모델에게 줄 문자열로(크기 제한). */
|
|
export function toolOutputText(result, max = 24000) { const s = JSON.stringify(result); return s.length > max ? s.slice(0, max) + `…(잘림: ${s.length}자 중 ${max}자)` : s; }
|