MINARA

시스템 설계

핵심 모듈, 불변 조건, 그리고 이 섹션의 모든 설계 문서 디렉터리

👥 이 섹션의 대상: Minara가 현재의 구조로 설계된 이유를 이해하려는 연구 지향 개발자, 그리고 불변 조건과 변경 위험 경계를 파악해야 하는 메인테이너. 모든 페이지는 "이것이 존재하는 이유"를 쉬운 말로 설명하는 콜아웃으로 시작하며, 실제 사용 사례 링크로 마무리됩니다. 기여자 전용 안내는 명확히 표시되어 있습니다.

처음 읽는 분께 권장하는 순서: 에이전트 루프(턴이 실행되는 핵심)부터 시작한 뒤, 스킬 시스템(기능을 패키징하는 방식)을 읽고, 관심 있는 기능이 의존하는 서브시스템 페이지로 이동하세요.

이 섹션은 Minara Agent가 어떻게 구축되었는지 설명합니다. 아래의 핵심 모듈 개요부터 시작한 뒤, 디렉터리를 따라 수정하려는 서브시스템의 심층 분석 페이지로 이동하세요.

디렉터리

런타임

  • 에이전트 루프: 사용자 메시지에서 최종 응답까지 단일 턴이 흐르는 방식.
  • 스킬 시스템: 라우팅, 활성화, 위험 게이트, 그리고 사용 가능한 스킬 전체 카탈로그.
  • LLM 통합: 프로바이더 추상화, 프롬프트 캐싱, 모델 라우팅.
  • 시나리오 분류기: 절차적 플레이북을 주입하고 스킬을 사전 로드하는 L0.5 의도 분류.

상태 및 저장소

  • 메모리 (서브섹션 개요): 연동되는 네 저장소(세션 메모리, 개인화, 역할 반성, 학습 시스템)가 턴에서 결합하는 방식.
  • 워크스페이스: Markdown 기반 아이덴티티 및 메모리 그라운드 트루스, 그리고 지속적인 대화 콘텐츠(차트, 보고서, 업로드)를 담는 아티팩트와 파일 스토어.

안전 및 실행

I/O 및 운영


핵심 모듈

Agent는 명시적 인터페이스를 통해 협력하는 소수의 모듈로 구성됩니다. 이 페이지에서는 각 모듈이 무엇을 담당하는지, 무엇에 의존하는지, 그리고 어떤 불변 조건을 유지하는지 순서대로 살펴봅니다.

core-modules diagram

app.ts: 합성 루트

apps/agent/src/app.ts는 레포지토리에서 다른 모든 모듈을 알고 있는 유일한 모듈입니다. 단일 createApp() 함수를 내보내며, 이 함수는 다음을 수행합니다.

  1. $dataDir/ 아래에 SQLite 데이터베이스를 엽니다(WAL 모드, FTS5 활성화).
  2. 권한 등급 훅과 분석→거래 경계 훅이 설치된 ToolRegistry를 구성합니다.
  3. 모든 툴 팩토리(createReadTools, createTradeTools, createMemoryTools, createFileTools 등)를 인스턴스화하고 등록합니다. 필요한 환경 변수가 없는 팩토리는 []를 반환하며 부팅 시 예외를 발생시키지 않습니다.
  4. BUILTIN_SKILLSapps/agent/src/skills/external/ 아래에 벤더링된 항목에 대한 buildExternalDomainSkills() 출력을 합쳐 SkillRegistry를 구성합니다.
  5. LLM 클라이언트, 레지스트리, 샌드박스 리졸버, 감사 로그 라이터를 주입하여 AgentLoop를 연결합니다.
  6. 게이트웨이가 구동할 조립된 App 객체를 반환합니다.

createApp()은 환경 변수와 설정이 주어졌을 때 순수 함수이므로, CLI REPL과 HTTP 게이트웨이는 동일한 런타임을 공유합니다. "X는 어디에 연결되는가"라는 질문의 답은 항상 app.ts입니다.

core/tool-registry.ts: 타입이 지정된 디스패치와 권한 등급

툴 레지스트리는 Agent가 할 수 있는 것을 구체적으로 표현합니다. 모든 도구는 ToolEntry입니다.

interface ToolEntry {
  name: string;
  toolSet: string;
  schema: ToolSchema;          // JSON Schema for LLM tool-use
  handler: ToolHandler;        // async (args) => string
  permissionTier: PermissionTier;
  isAsync: boolean;
  description: string;
  checkFn?: () => boolean;     // optional runtime availability gate
}

권한 등급

enum PermissionTier {
  READ_ONLY      = 1, // price, balance, trending, fear_greed, search
  CONFIRM_ONCE   = 2, // analyze, research, small swap, write_file
  ALWAYS_CONFIRM = 3, // perps, large swap, autopilot start/modify
  MANUAL_ONLY    = 4, // withdraw, external-address send, emergency stop
}

등급은 BeforeToolCallHook에 의해 강제됩니다. 권고 사항이 아닙니다. 훅이 ToolCallBlockedError를 발생시키면 에이전트 루프가 이를 포착하고 도구 결과로 LLM에 오류를 반환합니다. LLM은 이를 일반 도구 오류로 인식하지만, 감사 로그는 이를 별도로 기록합니다.

툴 세트

BUILTIN_TOOL_SETS는 도구를 includes 합성이 가능한 명명된 번들(read, trade, perps, file, browser 등)로 묶습니다. 스킬은 도구를 이름으로 참조하지만, 레지스트리와 게이트웨이는 툴 세트 단위로 대화합니다. 추론하기 쉽고 게이팅하기도 쉽기 때문입니다. 크론 Autopilot 턴은 allowedToolSets: ["read", "memory", "web"]만으로 실행되며 그 외에는 없습니다.

컨텍스트 전파

레지스트리는 AsyncLocalStorage를 사용하여 subagent를 통한 서브 에이전트 호출과 스킬이 실행하는 도구 시퀀스를 포함한 비동기 도구 호출 전반에 ToolCallContext를 전달합니다. 훅은 ToolRegistry.currentContext()를 읽어 턴별 불변 조건(분석→거래 경계, allowedToolSets 허용 목록, 비상 정지)을 적용합니다.

core/agent-loop.ts: 계획 → 호출 → 관찰 → 결정

에이전트 루프는 순수한 while (iterations < max) 오케스트레이터입니다.

  1. 현재 SkillSession 상태를 사용하여(prompt-builder.ts를 통해)시스템 프롬프트를 구성합니다.
  2. 현재 활성화된 스킬이 허용하는 도구 세트(턴의 allowedToolSets와 교집합)로 LLM을 호출합니다.
  3. 각 도구 호출에 대해 다음을 수행합니다.
    • 명시된 의도가 실제 도구 호출과 일치하는지 검증합니다.
    • BeforeToolCallHook을 실행하는 레지스트리의 디스패치를 실행합니다.
    • 호출과 결과를 감사 로그에 저장합니다.
  4. 결과를 새 사용자 메시지로 다시 전달합니다.
  5. stop_reason: "end_turn"이 반환되거나 max_iterations에 도달하면 종료합니다.

턴 상태(위험 한도, 신호 컨텍스트)는 루프가 runInContext(ctx, fn)으로 래핑하는 ToolCallContext 객체에 저장되어 모든 도구 호출이 동일한 상태를 봅니다.

전체 설명은 에이전트 루프를 참조하세요.

core/prompt-builder.ts: 시스템 프롬프트 조립

시스템 프롬프트는 문자열 연결이 아닌 선언된 블록으로 구성됩니다.

system: [
  { type: "text", text: identityPrompt,     cache: true  },
  { type: "text", text: skillCatalog,       cache: true  },
  { type: "text", text: activeSkillPrompts, cache: false },
  { type: "text", text: signalContextBlock, cache: false },
]

cache: true로 표시된 블록은 Anthropic의 프롬프트 캐시(5분 TTL, 비용 10% 절감)를 통해 전달되므로 신원 및 카탈로그가 캐시에 유지됩니다. 매 턴마다 변경되는 블록(활성 스킬, 신호 컨텍스트, 대기 중인 확인)은 캐시되지 않습니다. 이 설계는 중요한 구조입니다. 프롬프트 순서가 잘못되면 "Claude 호출 한 번"처럼 보이는 것이 세 번의 캐시 미스로 바뀔 수 있습니다.

buildSystemPrompt()는 일반 문자열이 필요한 호출자(테스트, CLI의 --dump-prompt 모드)를 위해 buildSystemPromptBlocks()를 래핑합니다.

skills/: 스킬 레이어

DomainSkill은 완전히 선언적인 단위입니다.

{
  id: "minara.core",
  kind: "domain_skill",
  description: "Unified Minara API — markets, portfolio, spot/perps trading, sub-account management",
  prompt: "...≤ 800 tokens of instructions...",
  tool_names: [
    "get_price", "get_trending", "get_fear_greed", "search_tokens",
    "minara_account", "lookup_token", "minara_total_balance",
    "get_portfolio", "get_perps_positions", "minara_pnl",
    "swap_tokens", "buy_token", "sell_token", "transfer_token",
    "open_perps_position", "close_perps_position",
    "minara_perps_wallets_list", "minara_perps_wallet_sweep", /* ... */
  ],
  activation: "auto",
  priority: 50,
  routing: {
    lifecycle_stages: ["discover", "decide", "manage"],
    keywords: ["minara", "balance", "swap", "long", "short", "perp", "perps", "perps sub-account"],
    asset_classes: ["crypto_major", "crypto_alt", "crypto_meme", "stablecoin", "perps"],
  },
}

각 구성 요소는 다음과 같습니다.

  • SkillRegistry (apps/agent/src/skills/registry.ts): 카탈로그를 보유하고, 등록 시 requires_env 게이팅을 적용하며, 라우팅되지 않은 카탈로그 블록과 라우팅된 카탈로그 블록을 렌더링하고, 활성 ID의 프롬프트 조각을 연결하고, 도구 허용 목록을 수집합니다.
  • SkillSession (apps/agent/src/skills/session.ts): 턴별 가변 상태입니다. 어떤 스킬이 활성화되었는지, 어떤 위험 한도가 적용되는지, 어떤 확인이 대기 중인지를 담습니다. 레지스트리는 무상태이며, 세션이 활성화를 소유합니다.
  • Router (apps/agent/src/skills/router.ts): L0 결정론적 사전 필터를 실행합니다. 키워드 스코어링, 단계 분류, 자산 클래스 감지, 그리고 사용자 확인 없이 고위험 활성화를 단락시키는 L3 위험 게이트로 구성됩니다.
  • FinanceTaxonomy (apps/agent/src/skills/finance-taxonomy.ts): 라우터와 스킬이 공유하는 어휘(LifecycleStage, AssetClass, RiskTier)입니다.

라우팅 알고리즘과 활성화 흐름 전체는 스킬 시스템을 참조하세요.

tools/_shared/sandbox.ts: 파일시스템 경계

모든 파일 도구는 resolveInSandbox(relativePath)를 호출하며, 이 함수는 다음을 수행합니다.

  1. 절대 경로를 거부합니다.
  2. $dataDir/sandbox/files/ 아래에서 경로를 해석합니다.
  3. 각 경로 세그먼트를 순회하며 심볼릭 링크를 따라가고, 해석된 실제 경로가 여전히 샌드박스 루트를 접두사로 가지는지 확인합니다.
  4. 정규화된 절대 경로를 반환하거나 SandboxEscapeError를 발생시킵니다.

도구 핸들러는 원시 사용자 입력을 fs.* 인수로 직접 받지 않습니다. 도구가 시도하더라도(그래서는 안 되지만)리졸버가 디스크에 시스템 콜이 닿기 전에 경로 순회 시도를 거부합니다.

다른 리졸버로 파일을 여는 병렬 헬퍼를 작성하지 마세요. 단일 진입점 특성이 샌드박스를 방어 가능하게 만드는 핵심입니다.

tools/_shared/result.ts: 도구 결과 봉투

모든 도구 핸들러는 문자열을 반환하지만, 해당 문자열은 LLM이 안정적으로 파싱할 수 있도록 ok({...}), err("..."), 또는 errFromThrow(e)에 의해 태그가 붙은 봉투 형태로 구성됩니다.

{"ok": true,  "data": {...}}
{"ok": false, "error": "..."}

의도적으로 단순하게 설계되었습니다. LLM은 모든 도구에서 동일한 형태를 보기 때문에 "결과가 오류이면 사과하고 재시도"와 같은 프롬프트 로직을 도구마다 따로 작성할 필요 없이 한 번만 작성하면 됩니다.

gateway/: 두 진입점, 하나의 런타임

  • cli.ts는 LLM 출력을 stdout으로 스트리밍하고 동일한 AgentLoop를 통해 도구 호출을 라우팅하는 REPL입니다. 실행 첫 번째 줄로 config/load-env.ts를 임포트하여 process.env를 읽기 전에 .env가 채워지도록 합니다.
  • server.ts는 HTTP API를 노출합니다. HTTP 게이트웨이를 참조하세요. 동일한 createApp() 출력을 사용하므로 CLI와 HTTP 동작 간에 차이가 없습니다.
  • skills-cli.tsminara skills add/list/upgrade/remove 서브커맨드입니다. 외부 SKILL.md 패키지를 apps/agent/src/skills/external/에 벤더링하고 출력에 라이선스 감지 결과를 표시합니다.

config/load-env.ts: 환경 부트스트랩

두 가지 책임만 담당합니다.

  1. .env가 존재하면 Node 22의 네이티브 process.loadEnvFile()을 호출합니다.
  2. 어떤 변수가 파일에서 왔고 어떤 변수가 셸에서 왔는지 기록하여 시작 로그에 남깁니다.

모든 진입점의 가장 첫 번째 임포트로 부수 효과만을 목적으로 임포트됩니다. 새 진입점을 추가할 때 이를 빠뜨리면 하위에서 환경 변수 누락으로 인한 조용한 오류가 발생합니다.


아키텍처 원칙: 여기에 나열된 모든 모듈은 하나의 역할만 담당하며, 스택에서 위에 있는 모듈은 아래에 있는 모듈에만 의존합니다. 에이전트 루프는 레지스트리에, 레지스트리는 도구 레이어에, 도구 레이어는 샌드박스에 의존합니다. "위로" 의존하는 경우(예를 들어 도구 핸들러가 agent-loop.ts에서 임포트하는 경우)를 발견했다면, 역방향 엣지를 추가하는 것이 아니라 설계를 재구성해야 한다는 신호입니다.

목차