콘텐츠로 이동

aimon-core 통합 가이드 — aimon-cli를 레퍼런스로 따라가기

aimon-cliaimon-core를 어떻게 조립하는지 코드 단위로 따라가면서, 자신의 애플리케이션에 동일한 패턴을 이식하는 방법을 설명한다.

이 문서의 위치

이미 다음 문서들이 있다. 목적이 다르므로 함께 읽으면 좋다.

문서 목적
architecture.md aimon-core의 핵심 추상화(Tool, LlmClient, VirtualFileSystem 등) 레퍼런스
embedding-agent-in-application.md Spring Boot/SDK 임베딩의 권장 패턴, 스코프 정책, 멀티세션
agent-session-guide.md LiveSession API와 이벤트 스트리밍 사용법
scope-model.md 수명·소유권·소멸 책임의 규범 문서 — 이 문서의 스코프 서술은 전부 그것을 따른다
이 문서 aimon-cli의 실제 부트스트랩 코드를 한 줄씩 따라가며 "왜 그렇게 조립했는지"를 설명

aimon-cli는 코어가 제공하는 모든 확장 포인트(LLM, 파일시스템, 도구, 스킬, 훅, 스케줄링, MCP)를 한 곳에서 꽂아 쓰는 가장 완전한 레퍼런스 구현이다. 이 문서는 그 코드를 그대로 읽으며 자신의 호스트 애플리케이션에 옮길 때의 결정 포인트를 짚어준다.


목차

  1. 전체 그림
  2. 모듈 의존성과 빌드 설정
  3. 부트스트랩 흐름 — AimonCli.call()
  4. AgentSetupFactory.create()를 한 줄씩
  5. 구성 요소별 적응 가이드
  6. 라이프사이클과 스코프
  7. 최소 임베딩 예제
  8. 웹 애플리케이션으로 옮기기
  9. 기타 적응 시나리오
  10. 체크리스트

1. 전체 그림

aimon-cli는 세 개의 큰 단계로 동작한다.

[Picocli 진입점]                  [팩토리 부트스트랩]                  [세션 실행]
AimonCli.call()         ──▶      AgentSetupFactory.create(config)   ──▶  ReplSession.start()
- 옵션 파싱                       - LLM 클라이언트 생성                  - LiveSession.submit(input)
- 설정 로드                       - VirtualFileSystem 초기화             - 이벤트 스트림 구독
- AgentSetup 오픈                 - Tool/Skill/Hook 등록                  - 사용자 입력 큐잉
- AgentSetup.close()              - SchedulingEngine 시작
                                  - LiveSession 생성

여러분의 애플리케이션도 같은 세 단계를 따른다. Picocli 진입점만 자기 진입점(웹 핸들러, 배치 잡 등)으로 바꾸고, REPL을 자기 인터랙션 루프로 바꾼다. 가운데의 부트스트랩 단계는 거의 그대로 가져갈 수 있다.

레이어 다이어그램:

┌─────────────────────────────────────────────────────────────┐
│  Application                                                 │
│  AimonCli (CLI)  /  HTTP handler  /  Batch job              │
└──────────────────────────────┬──────────────────────────────┘
                               │ create(CliConfig)
┌──────────────────────────────▼──────────────────────────────┐
│  Composition root (AgentSetupFactory)                       │
│  LlmClient + VirtualFileSystem + AgentBundle +              │
│  ToolRegistry + SkillRegistry + HookRegistry +              │
│  SchedulingEngine + AgentRuntime +                          │
│  AgentExecutor + SessionRecordStore + LiveSession           │
└──────┬────────────┬──────────────┬──────────────┬───────────┘
       │            │              │              │
┌──────▼──┐    ┌────▼────┐   ┌─────▼──────┐  ┌────▼─────────┐
│ aimon-  │    │ aimon-  │   │ aimon-     │  │ aimon-       │
│ llm-*   │    │ filesys │   │ scheduling │  │ knowledge-*  │
└─────────┘    │ -*      │   │ -quartz    │  └──────────────┘
               └─────────┘   └────────────┘

aimon-core는 인터페이스만 정의한다. 실제 구현체는 별도 모듈에서 가져와 조립한다 — 이게 aimon-cli가 보여주는 핵심 패턴이다.


2. 모듈 의존성과 빌드 설정

aimon-clibuild.gradle.kts

modules/aimon-cli/build.gradle.kts:

plugins {
    `java-library`
    application
}

application {
    mainClass.set("at.aimon.cli.AimonCli")
}

dependencies {
    // Core 모듈 (인터페이스 + Orca 실행기)
    implementation(project(":aimon-core"))

    // LLM 구현체 — 필요한 것만 골라 넣는다
    implementation(project(":aimon-llm-anthropic"))
    implementation(project(":aimon-llm-openai"))

    // CLI 전용 (자신의 앱에서는 불필요)
    implementation(libs.picocli)
    implementation(libs.jline)
    implementation(libs.jansi)

    // 설정 파싱 (선택)
    implementation(libs.jackson.databind)
    implementation(libs.jackson.dataformat.yaml)
    implementation(libs.snakeyaml)

    // 로깅
    implementation(libs.logback.classic)
    implementation(libs.slf4j.api)
}

자기 애플리케이션의 의존성

최소 구성:

dependencies {
    implementation(project(":aimon-core"))      // 또는 published artifact
    implementation(project(":aimon-llm-openai")) // 적어도 LLM 하나
}

필요에 따라 추가:

모듈 언제 필요한가
aimon-llm-openai / aimon-llm-anthropic LLM 호출 (둘 중 하나는 필수)
aimon-filesystem-gridfs MongoDB GridFS를 가상 파일 시스템 백엔드로
aimon-filesystem-s3 S3/MinIO를 백엔드로
aimon-scheduling-quartz 작업 스케줄링이 필요하면
aimon-knowledge-opensearch 벡터 검색 기반 KnowledgeStore
at.aimon.sandbox:aimon-sandbox-docker / -kubernetes (별도 저장소) 컨테이너/파드에 격리된 명령 실행 도구가 필요하면
at.aimon.browser:aimon-browser-playwright (별도 저장소) 브라우저 자동화 도구가 필요하면

모듈 의존성 규칙 (.claude/rules/architecture.md): 구현 모듈은 implementation(project(":aimon-core"))로만 코어를 참조한다. api()로 노출하지 않는다 — 코어 타입이 트랜지티브 의존성으로 새는 것을 막기 위해.


3. 부트스트랩 흐름 — AimonCli.call()

modules/aimon-cli/src/main/java/at/aimon/cli/AimonCli.java:67-136

public Integer call() {
    // (1) 설정 로드
    CliConfigLoader configLoader = new CliConfigLoader();
    CliConfig config = loadConfiguration(configLoader);

    // (2) CLI 옵션을 설정에 반영 (스트리밍 토글, 초기 예산)
    if (streaming != null) {
        config.getCliSettings().setStreaming(streaming);
    }
    ExecutionBudget initialBudget = buildInitialBudget();

    // (3) AgentSetup 오픈 (try-with-resources)
    AgentSetupFactory agentFactory = new AgentSetupFactory();
    try (AgentSetupFactory.AgentSetup agentSetup = agentFactory.create(config)) {

        // (4) 인터랙션 루프 시작 — CLI에서는 REPL
        ReplSession replSession = new ReplSession(agentSetup, cliSettings, initialBudget);
        replSession.start();
    }
    return 0;
}

자신의 앱에서 옮길 때의 매핑:

AimonCli의 단계 여러분의 코드
configLoader.load(path) Spring @ConfigurationProperties, env-vars, 커스텀 YAML 파서 등
factory.create(config) 그대로 유지AgentSetupFactory를 직접 호출하거나, 그 내부 로직을 자신의 컴포지션 루트로 옮긴다
try-with-resources Spring @Bean(destroyMethod = "close") / Quarkus @PreDestroy 등으로 라이프사이클 위임
replSession.start() HTTP 핸들러, 배치 잡, WebSocket 메시지 수신 루프

3.1 ${VAR} — 어디서 풀리는가

설정 파일의 모든 스칼라 값과 모든 매핑 키에서 풀린다. ${NAME} 은 환경 변수 NAME 의 값으로 바뀌고, 값이 없는 변수는 변수 이름과 그것이 적힌 키를 함께 말하며 기동을 실패시킨다Environment variable not set: OPENAI_KEY (at memory.dreamer.scorer.embedding.apiKey). 참여하는 필드의 목록은 없다. 목록이 있던 동안 그 목록은 llm 의 문자열 필드 셋과 capability 맵 키뿐이었고, memory 블록이 자기 자격증명을 갖게 되었을 때 아무도 그것을 늘리지 않아서, CLI 자신이 배포하는 default-config.yaml 이 loader 가 방문하지 않는 블록에 apiKey: "${OPENAI_KEY}" 를 예시로 싣고 있었다.

llm:
  apiKey: "${OPENAI_API_KEY}"     # 문자열
  timeout: "${LLM_TIMEOUT}"       # 정수 — 바인딩 전에 풀리므로 이것도 된다
  anthropic:
    thinkingMode: "${MODE}"       # enum — 마찬가지
  modelCapabilities:
    ${DEPLOYMENT}:                # 매핑 키
      supportsSamplingParameters: false
mcp:
  servers:
    - command: "npx"
      args: ["-y", "${PACKAGE}"]  # 배열 원소
memory:
  storagePath: "${HOME}/memory/representations.jsonl"   # 값의 일부여도 된다

규칙의 나머지 절반은 이렇다.

  • 같은 이름으로 풀리는 두 형제 키는 거절한다. yaml 은 같은 키를 두 번 적는 것을 막지만 ${A}${B} 가 같은 값으로 풀리는 것은 막지 못하고, 그때 뒤엣것이 앞엣것을 덮으면 아무도 보고하지 않는다.
  • 한 번만 푼다. 변수의 값이 다시 ${OTHER} 이면 그대로 남는다.
  • 리터럴 ${ 를 적는 escape 는 없다.
  • 플레이스홀더가 없는 스칼라는 건드리지 않는다. 파서가 읽은 원문이 그대로 디시리얼라이저에 닿으므로 thinkingMode: off 는 여전히 off 를 뜻한다 — off 는 YAML 1.1 의 boolean 이라 원문 텍스트만이 그것을 no · false 와 구별한다.

확장은 Jackson 이 바인딩하기 전에 토큰 스트림 위에서 일어난다 (at.aimon.cli.config.PlaceholderExpandingParser). 그래서 스타터와 CLI 가 플레이스홀더를 푸는 시점에 대해 같은 답을 한다 — 스타터는 Spring 이 바인딩 전에 풀고, CLI 도 이제 그렇다.


4. AgentSetupFactory.create()를 한 줄씩

modules/aimon-cli/src/main/java/at/aimon/cli/factory/AgentSetupFactory.java:687-902

이 메서드 하나가 aimon-core의 모든 조립 패턴을 담고 있다. 단계별로 본다.

아래 인용한 줄 번호는 그 순간의 좌표일 뿐이고 이 파일은 계속 자란다(트레이싱, peer memory, dreamer, rewake, 세션 체크포인트, GraalJS가 차례로 들어왔다). 번호가 어긋나면 메서드 이름으로 찾는다 — 이 문서가 참조하는 헬퍼는 전부 create* / build* / configure* / register* 접두어를 가진다.

4.1 LLM 클라이언트 생성 (line 690)

final LlmClient llmClient = createLlmClient(config);

내부적으로 LlmClientFactory.create()가 provider 문자열로 분기한다 (LlmClientFactory.java:16-28):

return switch (provider) {
    case "anthropic" -> createAnthropicClient(config);
    case "openai"    -> createOpenAIClient(config);
    default          -> throw new ConfigurationException("Unsupported LLM provider: " + provider);
};

각 빌더는 SDK별 설정 객체(AnthropicConfig, OpenAIConfig)를 만들어 apiKey, model, timeout, baseUrl 을 주입한다. 여기에 양쪽 모두 둘이 더 붙는다 — llm.modelCapabilities 가 있으면 그것으로 모델 capability registry 를 만들어 modelCapabilityRegistry(...) 로 넘기고, llm.reasoningEffort 가 있으면 그대로 넘긴다 (anthropicConfig(...) · openAiConfig(...)).

provider 를 바꿀 때 — agent.name 도 함께 바꾼다

에이전트 요청마다 실리는 모델 이름은 llm: 블록이 아니라 에이전트 정의에서 온다. agent.name 이 클래스패스 번들 agents/<name>/ 을 고르고, 두 클라이언트는 모두 modelConfig.getName().orElse(config.getModel()) 을 보낸다 — 정의에 model.name 이 있으면 그것이 llm.model 을 이기며, 번들로 들어 있는 정의는 전부 그것을 적고 있다. 배포된 설정의 agent.name: default 는 OpenAI 모델을 적는다(메인 에이전트 gpt-5.6-terra, explore 서브에이전트 gpt-5.1). 그래서 llm: 블록만 Anthropic 으로 고치면 그 이름들이 Anthropic 으로 나가고, Anthropic Messages API 는 2026-09-10 에 gpt-5.6-terra 에 HTTP 404 not_found_error 를 돌려주었다.

Anthropic 으로 바꾼 설정은 이렇다.

llm:
  provider: anthropic
  apiKey: "${ANTHROPIC_KEY}"
  model: claude-sonnet-4-5
agent:
  name: default-anthropic

번들의 explore 서브에이전트는 모델을 적지 않고 메인 에이전트의 모델로 돈다 — default-anthropic 에서는 claude-sonnet-4-5 다(모델을 적는 것은 defaultexplore 뿐이고, 그것은 gpt-5.1 이다). 모델을 적지 않은 서브에이전트는 메인 에이전트가 도는 모델로 돌고, 메인 에이전트의 정의도 모델을 적지 않았다면 클라이언트의 기본 모델로 돈다 — llm.model 을 적었으면 그 값이고, 적지 않았다면(anthropic 에서만 그럴 수 있다) Anthropic 클라이언트에 내장된 기본 모델이다.

함께 바꾸는 키는 다섯이다.

  • provider
  • apiKey — 그 벤더의 키
  • baseUrl — 지운다. 배포된 파일이 OpenAI 의 호스트를 적고 있으므로, 남겨 두면 요청이 계속 그 호스트로 간다
  • model — 에이전트의 모델은 아니지만 아래 문단의 자리들이 여전히 쓰므로, OpenAI 이름을 남겨 두면 메모리와 위키 생성이 그 이름을 Anthropic 으로 보낸다
  • agent.name

llm.model 이 여전히 닿는 곳은 peer memory(dialectic 엔진 · deriver · reconciler, 그리고 memory.dreamer.scorer.llm.model 이 없으면 dreamer 와 그 LLM 판정기)와 위키 페이지 생성이다. model.name 이 없는 정의는 이 값으로 돈다. anthropic 에서는 생략할 수 있다 — 그러면 메모리, 위키 생성, 그리고 model.name 이 없는 정의가 Anthropic 클라이언트의 기본 모델로 돌고, memory 를 켰다면 기동할 때 그 모델 이름을 한 줄로 알린다. 시작 배너의 Agent bundle: 줄은 불러온 번들을, LLM Provider: <provider> (<model>) 의 괄호 안은 메인 에이전트의 요청이 싣는 모델을 보여 준다 — 서브에이전트가 따로 적은 모델은 보여 주지 않는다.

불러온 정의가 다른 벤더의 모델을 적고 있으면 시작할 때 경고한다 — 기동을 멈추지는 않는다. 배포된 default 에이전트라면 provider: anthropic 에서 baseUrl 이 없거나, Anthropic 의 호스트이거나, 배포된 파일에서 남은 OpenAI 의 호스트일 때 뜬다. 그 밖의 baseUrl 뒤에서는 조용하고, 어느 벤더도 제 것이라 하지 않는 이름에도 조용하다 — 게이트웨이가 자기 배포에 붙인 이름 같은 것이다. 경고는 배너보다 먼저 터미널에 찍히고 ~/.aimon/logs/aimon.log 에도 남는다. 줄마다 정의의 키(메인 에이전트는 model.name, 서브에이전트는 model)와 그것을 읽은 자리를 적는다 — 번들 파일은 classpath 로, 사용자 서브에이전트는 CLI 작업 디렉토리 아래의 절대 경로로. 그 디렉토리는 jar 가 있는 디렉토리이고 jar 로 돌리지 않으면 user.dir 이며(./gradlew :aimon-cli:run 에서는 modules/aimon-cli), 배너의 Working Directory: 줄이 그 값이다. 처방은 무언가를 실제로 바꾸는 것만 내놓는다.

모델이 얼마나 생각할지 — llm.reasoningEffort

llm:
  provider: openai
  model: gpt-5.1
  reasoningEffort: medium      # none | minimal | low | medium | high -- 대소문자 무관

llm.anthropic 블록과 달리 두 프로바이더가 모두 읽는다. 이름이 중립 SPI 타입 (at.aimon.core.llm.ReasoningEffort) 자신의 것이고, "이 호출이 얼마나 숙고해야 하는가" 라는 물음이 벤더마다 다른 것을 뜻하지도 않기 때문이다. 답으로 하는 일은 다르다 — OpenAI 는 rung 파라미터를 보내고 Anthropic 은 토큰 예산으로 옮긴다 — 그리고 그 번역이 중립 enum 이 있는 이유다.

에이전트 정의의 model.reasoningEffort 가 이것을 이긴다. 값이 이 모델의 사다리에 없으면 파라미터는 빠지고 보고된다 — 올려서 맞추지 않는다. 운영자가 하지 않은 요청이 조용히 나가는 것보다 낫기 때문이다.

Anthropic 에서는 llm.anthropic.thinkingMode 가 기본값 off 가 아니어야 뜻이 있다. off 아래에서는 요청에 thinking 파라미터가 아예 실리지 않으므로 effort 가 닿을 곳이 없고, 클라이언트가 그 사실을 프로세스당 한 번 WARN 으로 말한다. reasoningEffort: none 은 예외다 — 그것과 off 는 같은 것을 뜻하므로 아무 말도 하지 않는다.

게이트웨이가 모델 이름을 바꿔 부를 때 — llm.modelCapabilities

baseUrl 을 Azure 배포나 벤더 호환 게이트웨이로 돌리면 그 게이트웨이가 모델을 자기 이름으로 노출할 수 있다(gpt-5-miniprod-assistant 로). 내장 capability 표는 모델을 실제 이름으로 알고 있으므로 그 이름은 표에 걸리지 않고 fail-open 경로로 떨어지며, 그 결과 temperature 를 받지 않는 모델에 그것이 실려 나가 HTTP 400 을 맞는다. 그 이름에 대해 "이 모델은 무엇을 받는가" 를 적는 자리가 이 블록이다.

이 블록은 두 프로바이더 모두 읽는다. 내장 표도 두 벤더를 서술한다 — gpt-* · o* 행과, 샘플링 파라미터를 거절하는 claude-* 행이다(실측한 모델 이름 여섯 개에 문서 기반인 claude-mythos 계열이 더해진다). 그래서 아래 예제의 provideranthropic 이어도 된다.

llm:
  provider: openai
  baseUrl: https://gateway.internal/v1
  apiKey: "${OPENAI_KEY}"
  model: prod-assistant
  modelCapabilities:
    prod-assistant:
      supportsSamplingParameters: false

맵 키는 model 이 부르는 그 이름이고, 대소문자를 가리지 않는다(Prod-Assistant 로 적고 prod-assistant 로 조회해도 맞는다). ${VAR} 도 풀리므로 model: ${DEPLOYMENT} 을 쓰는 배포가 자기 모델을 서술할 수 있다.

여덟 플래그가 있고 전부 선택이다. 적지 않은 것은 ModelCapabilities.unknown() 의 값을 갖는다. 내장 표가 모르는 이름 — 위 예제처럼 개명된 게이트웨이 배포 — 에 대해서는 그것이 곧 오늘의 동작이고, 그래서 위의 한 줄이 400 에 대한 완전한 답이다. 내장 표가 아는 이름(claude-sonnet-5, gpt-5-mini)에 대해서는 그렇지 않다 — 두 표 아래의 "항목은 그 이름의 행 전체다" 문단을 읽는다. 다 요구하지 않는 이유는 게이트웨이 운영자가 "temperature 가 400 을 낸다" 는 알아도 "이 모델이 reasoning trace 를 되싣는가" 는 모르기 때문이며, 그 칸을 억지로 채우면 /v1/responses 가 없는 게이트웨이에서 400 이 404 로 바뀐다.

적지 않으면
supportsSamplingParameters temperature · top_p · 두 penalty 를 실을 수 있는가 true — 호출자가 설정한 값은 그대로 나간다
supportsReasoningEffort reasoning-effort 파라미터가 이 모델의 요청 표면에 있는가 false — 프레임워크가 만들어 내지 않는다
supportsToolsWithReasoning 도구와 non-NONE effort 를 한 요청에 함께 실을 수 있는가 true — 근거 없이 좁히지 않는다
supportsReasoningTraceRoundTrip reasoning trace 를 다음 턴에 되실어야 추론이 이어지는가 false — Chat Completions 경로를 유지한다
lowestReasoningEffort 이 모델의 effort 사다리가 어디서 시작하는가 (nonehigh). 거기서 위로 전부 받는다는 뜻이다 minimal 부터 high 까지
acceptedReasoningEfforts 받는 rung 전부, 목록으로 — 사다리 중간에 구멍이 있을 때 쓰는 일반형 ([none, low, medium, high]). 빈 원소(~, 값 없는 -)는 건너뛰지 않고 몇 번째인지를 부르며 기동을 실패시킨다 위 칸과 같다
supportsReasoningSummary 이 모델이 추론 요약 요청(reasoning.summary)을 받는가. OpenAI Responses 경로만 읽는다supportsReasoningTraceRoundTrip: true 인 이름만 그 경로에 가므로, reasoning.effort 는 받고 reasoning.summary 는 400 을 내는 게이트웨이를 적는 자리다 true — 호출자가 요청한 것을 빼앗지 않는다
thinkingDialect 이 모델의 thinking 요청이 어느 모양인가 — unknown · either · budgeted · adaptive. anthropic 분기만 읽는다. 아래 llm.anthropic.thinkingMode: auto 가 물어보는 값이 이것이다 unknown — 표가 답할 수 없다는 뜻이고, 클라이언트는 설정된 것을 그대로 둔다

이 선언은 내장 를 대체하지 않고 확장한다. exact 항목으로 등록되므로 기존 exact > prefix 규칙이 그대로 "사용자가 이긴다" 를 뜻하고, 그 승리는 이름 하나만큼이다 — gpt-5 를 선언하면 정확히 그 이름만 바뀌고 gpt-5-mini 는 여전히 내장 gpt-5 prefix 가 답한다. 설정에서 prefix 를 선언하는 방법은 없다: prefix 우선순위는 등록 순서이고, yaml 의 줄 순서가 그것을 정하게 만들 자리가 아니다.

내장 표에 있는 이름을 서술한다면, 항목은 그 이름의 행 전체다. 표를 확장하는 것과 한 을 패치하는 것은 다르다. 항목 하나는 그 이름의 행 전체이므로, 적지 않은 플래그는 그 행이 말하던 값이 아니라 fail-open 값으로 떨어진다. 내장 표가 모르는 이름에는 차이가 없다 — 덮을 행이 없기 때문이다. 아는 이름에는 있고, claude-* 행이 그 사례다: 그 행은 방언과 샘플링 억제 두 가지를 말한다.

# 틀림 — 억제가 되돌아온다
modelCapabilities:
  claude-sonnet-5:
    thinkingDialect: unknown

# 맞음 — 그 행이 말하던 플래그를 전부 다시 적는다
modelCapabilities:
  claude-sonnet-5:
    thinkingDialect: unknown             # 또는 adaptive / budgeted
    supportsSamplingParameters: false    # 내장 행에서 옮겨 적은 것 — 여기서는 선택이 아니다

위쪽은 supportsSamplingParameters 를 fail-open 인 true 로 되돌리므로 temperature 가 400 을 내는 모델로 나간다 — 그리고 경고가 없다. 억제 WARN 은 플래그가 false 일 때만 울리기 때문이다. thinkingMode: extended 로 피할 수도 없다: 그 분기는 temperature 는 빼지만 top_p 는 여전히 싣는다.

규칙은 한 줄이다 — 항목이 행 전체이므로, 내장 행이 말하던 플래그를 전부 옮겨 적는다. claude-* 이름에 대해서는 그것이 위의 두 개다.

사다리를 적는 두 키는 서로 배타적이다. lowestReasoningEffort 는 "여기서 시작해서 끝까지" 라는 흔한 경우의 축약이고, acceptedReasoningEfforts 는 중간이 비어 있는 사다리를 위한 것이다 — 내장 표의 gpt-5.6-terra 행이 실측된 그 경우다(none 을 받고 minimal 을 거절한다). 둘을 같이 적으면 기동이 실패한다: 둘 중 어느 쪽을 뜻했는지 답할 방법이 없기 때문이다.

조용히 무시되지 않는 것들 — 모르는 플래그 이름, 잘못된 lowestReasoningEffort 값, 두 사다리 키를 함께 적은 항목, 빈 acceptedReasoningEfforts 목록, 아무것도 선언하지 않은 항목, 빈/공백이 붙은 이름, 대소문자만 다른 두 이름, 그리고 같은 이름으로 풀리는 두 ${VAR}. 전부 ConfigurationException 이고 메시지가 고쳐야 할 yaml 키를 부른다. provider: anthropic 아래의 선언은 더 이상 거절되지 않는다 — 그 분기도 이 registry 를 읽기 때문이다.

같은 축의 스타터 프로퍼티는 embedding-agent-in-application.md 에 있다. 표기는 섞이지 않는다 — CLI 는 camelCase, 스타터는 kebab-case 다.

Anthropic 의 thinking 을 조율할 때 — llm.anthropic

바로 위 블록과 달리 이 블록은 anthropic 분기만 읽는다. 세 키의 이름이 전부 Anthropic 의 어휘이기 때문이다 — "thinking" 은 이 저장소가 다른 자리에서 ReasoningEffort · ReasoningTrace 라고 부르는 것에 대한 그 벤더의 단어이고, budget_tokens 는 요청 본문의 필드 이름 그대로이며, "thinking block" 은 서명이 붙은 와이어 콘텐츠 블록이다. 그래서 provider: openai 아래에 적힌 이 블록은 무시되지 않고 기동을 실패시킨다.

이 블록을 적지 않으면 요청은 글자 하나 바뀌지 않는다. thinking 블록의 포착과 되싣기는 이 설정과 무관하게 늘 일어나므로, thinking 이 기본으로 켜진 최신 모델을 쓰는 배포는 아무것도 설정하지 않아도 그 이득을 이미 받고 있다. 이 블록이 여는 것은 조율이다 — 방언을 고르고, 예산을 정하고, 되싣기를 끄는 것.

llm:
  provider: anthropic
  apiKey: "${ANTHROPIC_KEY}"
  model: claude-sonnet-5
  anthropic:
    thinkingMode: auto
    replayThinkingBlocks: true
적지 않으면
thinkingMode 어느 thinking 요청 모양을 보낼 것인가 (아래 네 값) off — thinking 파라미터를 보내지 않는다
thinkingBudgetTokens extended 방언의 명시적 budget_tokens 호출의 reasoning effort 에서 파생된다
thinkingDisplay 모델의 thinking 텍스트를 요청하고 흘려보낼 것인가 (summarized) 아무것도 요청하지 않고 아무것도 흘리지 않는다
replayThinkingBlocks 저장된 thinking 블록을 다음 요청에 되실을 것인가 true — 되싣는다

thinkingMode 의 네 값이다. 대소문자를 가리지 않는다.

무엇을 보내는가
off (기본) thinking 파라미터를 보내지 않는다. 모델이 생각하지 않는다는 뜻이 아니다 — 최신 모델에서는 생각하고, 그 블록은 그대로 포착·되싣기된다
extended thinking: {"type": "enabled", "budget_tokens": N} — 예산 방언
adaptive thinking: {"type": "adaptive"}output_config.effort — 현 세대 모델의 방언
auto capability 표가 이 모델이 말한다고 적은 방언. 여러 Claude 모델을 도는 배포가 설정 하나로 갈 수 있는 값이다

내장 표가 BUDGETED 로 적은 세 가족(claude-opus-4-5 · claude-sonnet-4-5 · claude-haiku-4-5)에서 auto 는 예산 방언을 보내고, 그 예산은 호출의 ReasoningEffort 에서 나온다 — 적지 않았으면 가운데 칸인 4096 이다. 그 예산에도 아래 thinkingBudgetTokens 의 상한이 똑같이 걸린다. 에이전트 정의가 model.maxTokens 를 적지 않았으면 max_tokensAnthropicConfig 의 기본값 4096 이므로 예산은 4095 로 clamp 되고 WARN 이 한 번 뜨며, 답변에 남는 것은 1 토큰이다. CLI 가 함께 배포하는 에이전트 정의는 모두 maxTokens: 40000 이라 그 정의로는 이 일이 일어나지 않는다. 처방은 둘이다 — 에이전트 정의의 model.maxTokens 를 올리거나, 위의 llm.reasoningEffortlow(2048) 나 minimal(1024) 로 내린다(에이전트 정의에 model.reasoningEffort 가 있으면 그쪽을). 경고가 말하는 처방은 앞의 것 하나뿐이다. 이것은 빠뜨린 것이 아니라 결정이며, 근거와 기각한 대안은 anthropic-thinking.md §6.2 에 있다.

두 방언은 모델마다 배타적이고 틀린 쪽을 보내면 HTTP 400 이다. 그것이 auto 가 있는 이유이며, 동시에 auto 의 한계이기도 하다 — 표가 이름을 모르는 모델에는 아무것도 보내지 않고 경고한다. 게이트웨이 뒤에서 모델을 개명해 쓴다면 그 이름을 바로 위 llm.modelCapabilitiesthinkingDialect 로 선언하는 것이 처방이고, 두 블록이 만나는 자리가 정확히 여기다.

llm:
  provider: anthropic
  baseUrl: https://gateway.internal
  model: prod-claude
  anthropic:
    thinkingMode: auto
  modelCapabilities:
    prod-claude:
      thinkingDialect: adaptive        # 실측한 값을 적는다. 모르면 unknown 이 정직한 답이다

내장 표가 아는 이름(claude-sonnet-5 등)에 대해 같은 것을 적을 때는 supportsSamplingParameters: false 를 함께 적는다 — 항목이 행 전체이기 때문이며, 위 llm.modelCapabilities 절의 "항목은 그 이름의 행 전체다" 문단이 그 이유다.

thinkingBudgetTokens 는 독립된 노브가 아니라 extended 의 것이다. auto · adaptive · 기본 off 와 함께 적으면 기동이 실패한다 — 조용히 무시되지 않는다. auto 아래에서 거절하는 이유는 AnthropicThinkingMode.AUTO 가 적어 둔 그대로다: 방언이 정해지기 전에는 숫자에 뜻이 없고, auto 에서 방언은 요청을 만들 때까지 정해지지 않는다. 가장 흔한 실수는 셋 중 세 번째다 — 모드를 빼고 예산만 적는 것. 그때 모드는 기본값 off 이므로 그 숫자는 아무 데도 닿지 않고, 그래서 그것도 기동 실패다.

값 자체에도 두 개의 상한이 있다.

  • 아래로 1024. API 가 그보다 작은 예산을 매 요청 거절하므로 기동 시점에 거절한다.
  • 위로 max_tokens 미만. thinking 토큰이 max_tokens 에 함께 계산되므로 클라이언트가 max_tokens - 1 로 clamp 하고 그 사실을 WARN 으로 남긴다. max_tokens 는 에이전트 정의의 model.maxTokens 이고, 적지 않았으면 AnthropicConfig 의 기본값 4096 이다 — 그런 에이전트에서 thinkingBudgetTokens: 8000 은 실제로 4095 로 나간다. CLI 가 함께 배포하는 에이전트 정의는 모두 maxTokens: 40000 이라 그 정의로는 8000 이 그대로 나간다. 천장을 올리는 키는 여기 없다 — 그것은 에이전트 정의의 model.maxTokens 이고, 세 번째 설정 표면이다.

auto · adaptive 아래에서 "얼마나 생각할까" 를 정하는 것은 호출의 ReasoningEffort 이고, 그것을 배포 단위로 적는 키가 위의 공통 llm.reasoningEffort 다.

thinkingDisplay 는 한 키가 두 일을 하고, 어느 쪽이 무는지는 방언이 정한다. adaptive 에서는 요청에 thinking.display 를 쓰고 — 그것이 없으면 이 세대의 모델은 thinking 텍스트를 아예 주지 않는다 — 동시에 그 텍스트를 사용자에게 흘려보내는 게이트를 연다. extended 에서는 델타가 이미 오고 있으므로 게이트만 열고 display 는 보내지 않으며, 클라이언트가 그 사실을 한 번 WARN 으로 말한다. 기본값 off 아래에서는 아무것도 닿지 않고, 역시 한 번 말한다. 값은 summarized 하나다 — 서버가 받아 주는 다른 값 omitted 은 서버 자신의 기본값이라 적지 않은 것과 동작이 같고, 이 키의 나머지 절반이 흘려보내기 게이트를 여는 것이므로 omitted 은 "채널을 열고 아무것도 담지 말라" 는 뜻이 된다. 그래서 제공하지 않는다.

REPL 은 그 텍스트를 답변과 구별되게 흐린 색으로, [thinking] 표시를 앞세워 인쇄한다. 적지 않으면 요청도 화면도 이 키가 없던 때와 같다 — 매 요청 출력 토큰을 쓰는 값이므로 opt-in 이다.

replayThinkingBlocks: false 는 이름 있는 실패 하나를 위한 비상구다 — "Invalid signature in thinking block. The block is bound to a different conversation." 서명은 시스템 프롬프트·도구·앞선 메시지가 그대로일 때만 유효한데 AIMON 은 매 이터레이션 시스템 프롬프트를 다시 렌더하고 클라이언트에서 컴팩션한다. 벤더의 처방이 이력에서 thinking 블록을 전부 떼는 것이고, false 가 그것이다. 대가는 기능 자체다 — 모델이 매 턴 추론을 다시 세운다.

이 블록 안에서도 ${VAR} 가 풀린다 — 값이 enum 이어도 그렇다. 확장은 바인딩 전에 토큰 스트림 위에서 일어나므로 필드의 자바 타입과 무관하다. 규칙은 §3.1 에 있고, 이 축에서 스타터와 CLI 는 이제 같은 답을 한다.

같은 축의 스타터 프로퍼티는 embedding-agent-in-application.md 에 있다. 여기서도 표기는 섞이지 않는다 — CLI 는 thinkingMode, 스타터는 thinking-mode 다.

OpenAI 전용 블록 — llm.openai

llm.anthropic 의 짝이고 같은 규칙을 따른다. openai 분기만 읽으므로 provider: anthropic 아래에 적힌 이 블록은 무시되지 않고 기동을 실패시킨다. 오늘 키는 하나다.

llm:
  provider: openai
  apiKey: "${OPENAI_API_KEY}"
  model: gpt-5.1
  openai:
    reasoningSummary: auto
적지 않으면
reasoningSummary 모델의 추론 요약을 요청하고 흘려보낼 것인가 (auto | concise | detailed) 아무것도 요청하지 않고 아무것도 흘리지 않는다

이 벤더에서 추론 자체는 encrypted_content — 설계상 암호문 — 이므로 요약이 사람이 읽을 수 있는 유일한 대리물이다. 그것이 이 키가 Anthropic 쪽의 thinkingDisplay 와 다른 이름을 가진 이유다.

Responses API 전용이다. 모델이 추론 트레이스 왕복을 지원하지 않거나 그 엔드포인트가 꺼져 있으면 요청은 Chat Completions 로 가는데 거기에는 이 파라미터가 없다 — 그 경우 클라이언트가 한 번 WARN 으로 말한다(조용히 아무것도 하지 않는 대신).

cli.tracing이 켜져 있으면 그 위에 한 겹이 더 붙는다 (line 697-712) — TracingLlmClient가 원본 클라이언트를 감싸고, 같은 Tracer가 실행기 팩토리에도 주입되어 턴/이터레이션/도구 span까지 한 트리에 모인다. 감싸는 대상은 에이전트 턴 경로뿐이다. 백그라운드 서브시스템(wiki 인덱싱, peer memory, dreamer)은 의도적으로 원본 llmClient를 그대로 받는다 — 그쪽 호출에는 턴 span 컨텍스트가 없어 감싸도 span이 생기지 않는다.

여러분의 적응 포인트: - 자체 LLM 게이트웨이가 있다면 LlmClient를 직접 구현해서 주입한다. LlmClientFactory를 안 거쳐도 된다. - LlmClient 인스턴스는 애플리케이션 스코프다. 한 번 만들고 모든 세션에서 공유한다. - 데코레이터를 얹을 거라면 CLI처럼 원본과 감싼 것을 둘 다 들고 어느 쪽을 어디에 넘길지 정한다. 하나로 합치면 백그라운드 작업까지 트레이스에 섞인다.

4.2 출력 포매터 + 셸 + 스킬 파서 (line 713-725)

final OutputFormatter outputFormatter = createOutputFormatter(config);
final LocalShell skillHookShell = new LocalShell();
final SkillParser skillParser = createShellAwareSkillParser(skillHookShell);
final AgentBundleLoader effectiveBundleLoader = (this.agentBundleLoader != null)
        ? this.agentBundleLoader
        : new AdaptiveAgentBundleLoader(DEFAULT_AGENT_BUNDLE_BASE_PATH,
                new MarkdownAgentDefinitionParser(),
                Thread.currentThread().getContextClassLoader(), skillParser);
final AgentBundle agentBundle = effectiveBundleLoader.load(extractAgentName(config));
  • OutputFormatter — 콘솔 색상/포매팅 담당. 자신의 앱에서는 SSE 스트리머, 로그 어펜더, WebSocket 송신기 등으로 대체한다.
  • LocalShell — 스킬 frontmatter의 shell 액션을 실행할 셸. AutoCloseableAgentSetup.close()에서 정리된다.
  • SkillParser — 마크다운 스킬 정의 파서. LocalShell을 주입해서 shell 훅이 실제 실행되게 한다.
  • AgentBundleLoaderagents/<name>/agent.md와 그 하위의 서브에이전트, 스킬을 한 번에 로드한다. 클래스패스에서 읽으므로 jar로 패키징된다.

여러분의 적응 포인트: - Agent 정의를 코드/DB에서 동적으로 만들고 싶으면 AgentBundle을 직접 빌드해서 AgentSetupFactory의 패키지-프라이빗 생성자로 주입한다. - 셸 실행 자체를 컨테이너에 격리하려면 VirtualShell직접 구현한다 — 내장 구현은 LocalShell 하나뿐이고, 샌드박스 모듈은 이 SPI 를 구현하지 않는다. 그쪽이 주는 격리는 셸 교체가 아니라 RunSandbox 등 도구 4종이다.

4.3 세션 레코드 저장소, 트랜스크립트 매니저, 메시지 큐, 파일 시스템 (line 726-733)

final SessionCheckpointMailbox sessionCheckpoints = createSessionCheckpointMailbox();
final InMemorySessionRecordStore sessionRecordStore = new InMemorySessionRecordStore();
final TranscriptManager transcriptManager = createTranscriptManager(sessionRecordStore, sessionCheckpoints);
final MessageQueueManager messageQueueManager = createMessageQueueManager();
final LocalFileSystem fileSystem = createFileSystem();

기본 구현 (AgentSetupFactory.java:1033, 1044, 1076, 1083):

private TranscriptManager createTranscriptManager(InMemorySessionRecordStore repository,
        SessionCheckpointMailbox checkpoints) {
    return new DefaultTranscriptManager(repository, checkpoints);
}

private SessionCheckpointMailbox createSessionCheckpointMailbox() {
    return SessionCheckpointMailbox.background();
}

private MessageQueueManager createMessageQueueManager() {
    return new DefaultMessageQueueManager(new InMemoryMessageQueueRepository());
}

private LocalFileSystem createFileSystem() {
    final String workingDirectory = getJarDirectory();
    final LocalFileSystem fileSystem = new LocalFileSystem(new LocalFileSystemConfig(workingDirectory));
    fileSystem.initialize();
    return fileSystem;
}

여기서 이름 세 개를 구분해 둬야 뒤가 편하다.

타입 무엇을 들고 있나 수명
SessionRecordStore SessionId로 식별되는 영속 세션 레코드 — 메시지 이력, SessionTotals, budgetOverride 저장소 자체는 애플리케이션 스코프, 항목은 세션별
TranscriptManager 그 레코드 위에서 LLM 메시지 교환(conversation)을 읽고 쓰는 매니저 애플리케이션 스코프
SessionCheckpointMailbox end-of-turn 저장 사이에 진행 중 세션을 자기 스레드에서 비동기로 흘려보내는 우편함 애플리케이션 스코프

sessionRecordStore를 팩토리 지역 변수로 끌어올려 둔 것이 의도적이다 (line 727-730 주석). 같은 인스턴스가 트랜스크립트 매니저(메시지 이력)와 아래에서 만드는 LiveSession(세션의 영속 side field 두 개 — sessionTotals, budgetOverride) 양쪽에 들어가야 한다. 두 개로 나누면 라이브 세션이 되쓴 누적치를 트랜스크립트 쪽이 못 보게 된다.

핵심 원칙 (CLAUDE.md): 상태를 가진 컴포넌트는 저장소를 인터페이스로 분리한다. CLI는 in-memory 구현으로 충분하지만, 멀티 인스턴스 환경에서는 SessionRecordStore/MessageQueueRepository를 분산 백엔드 구현으로 갈아끼우면 된다.

여러분의 적응 포인트: - 멀티 인스턴스: InMemorySessionRecordStoreaimon-session-mongodb / aimon-session-postgres / aimon-session-redis의 영속 구현. - 다중 사용자: LocalFileSystemGridFSFileSystem 또는 S3FileSystem. 사용자별로 작업 디렉터리를 분리한다. - MessageQueueManager는 같은 세션 내에서 producer(REPL)와 consumer(executor의 ReAct 루프)가 반드시 같은 인스턴스를 공유해야 한다 — 이게 mid-turn 사용자 입력 주입을 가능하게 한다.

자바 이름은 Session*인데 저장된 이름은 conversation_*이다. Mongo 컬렉션 (conversation_locks / conversation_inbox / conversation_signals), Postgres 테이블·채널 (conversation_*), 와이어 키("conversationId", "invokingConversationId"), Redis 키 prefix는 의도적으로 동결되어 있다 — 개명은 자바 식별자에서만 일어났고 이미 배포된 데이터의 마이그레이션을 강요하지 않기 위해서다. 경계는 ../migration/frozen-names.md 이다. 여러분의 스토어 구현이 그 이름들을 쓰고 있다면 그대로 두는 것이 맞다.

4.4 스킬 정책과 보류 턴 레지스트리 (line 738-767)

스킬 호출 승인 흐름을 담당한다. CLI에서는 사용자가 인터랙티브 프롬프트로 승인/거부하지만, 자신의 앱에서는 정책으로 자동 결정하거나 외부 승인 시스템에 위임할 수 있다.

final PendingTurnRegistry pendingTurnRegistry = new InMemoryPendingTurnRegistry();
final PendingTurnReaper pendingTurnReaper = createPendingTurnReaper(pendingTurnRegistry, outputFormatter);
// 승인은 두 스코프로 나뉜다 — 기본은 세션 단위이고, 사용자가 "이 에이전트에서 항상"이라고
// 명시한 답만 agent 단위 저장소로 간다. 정책 체인은 좁은 쪽(세션)을 먼저 본다.
final AgentApprovalStore agentApprovalStore = new InMemoryAgentApprovalStore();
final SessionApprovalStore sessionApprovalStore = new InMemorySessionApprovalStore();
// 번들(클래스패스) 스킬을 작업 VFS로 실체화해서 부속 파일(스크립트·레퍼런스·템플릿)이
// 에이전트가 읽을 수 있는 진짜 파일이 되고 ${AIMON_SKILL_DIR}가 resolve 되게 한다.
final SkillRegistry skillRegistry = OrcaAgentRuntimeFactory.buildMaterializedSkillRegistry(
        agentBundle, fileSystem, ".aimon/skills", ".aimon/bundled-skills",
        DEFAULT_AGENT_BUNDLE_BASE_PATH + "/" + extractAgentName(config) + "/skills",
        Thread.currentThread().getContextClassLoader(), skillParser);
final SkillInvocationPolicy skillInvocationPolicy =
        createSkillInvocationPolicy(sessionApprovalStore, agentApprovalStore);
final InteractiveSkillApprovalChannel skillApprovalChannel = new InteractiveSkillApprovalChannel(
        sessionApprovalStore, agentApprovalStore, outputFormatter);
final SkillPreflightScanner skillPreflightScanner = SkillPreflightScanner.builder()
        .policy(skillInvocationPolicy)
        .registry(skillRegistry)
        .approvalChannel(skillApprovalChannel)
        .build();

정책 체인은 createSkillInvocationPolicy (AgentSetupFactory.java:998-1003)가 조립한다. 순서가 계약이다:

return new SessionScopedSkillInvocationPolicy(sessionApprovalStore,          // 1. 세션 단위 (좁음)
        new ApprovalCachingSkillInvocationPolicy(agentApprovalStore,         // 2. 에이전트 전역
                RuleBasedSkillInvocationPolicy.builder()                     // 3. 규칙
                        .defaultDecision(SkillInvocationDecision.ASK).build()));

좁은 스코프가 먼저인 것은 취향이 아니라 유일하게 동작하는 순서다. 뒤집으면 이전에 준 에이전트 전역 허용이 먼저 답해버려서 "이 세션에서는 거부"가 영영 도달하지 못한다.

SessionApprovalStore라는 이름은 한 번 폐기됐다가 다른 뜻으로 재사용됐다. 옛 코드/옛 문서에서 이 이름은 AgentRuntimeId로 키잉되는 에이전트 전역 저장소를 가리켰고(이름이 거짓말을 하고 있었다), 그것은 지금 AgentApprovalStore (…skill.policy.agent)다. 지금의 SessionApprovalStore (…skill.policy.session)는 SessionId로 키잉되는 세션 단위 저장소이며 옛 ConversationApprovalStore의 후신이다. 이 파일에서 가장 뒤집기 쉬운 지점이므로 대응표는 scope-model.md §6을 본다. 승인의 의미는 하나도 바뀌지 않았다 — 에이전트 전역 결정은 여전히 TTL이 없고 /clear로 지워지지 않는다.

적응 포인트: - 헤드리스 환경(배치, 웹 API): RuleBasedSkillInvocationPolicy.builder().defaultDecision(SkillInvocationDecision.ALLOW) 등으로 자동 허용/거부. - 외부 승인 시스템: SkillApprovalChannel을 직접 구현해서 Slack/이메일/대시보드로 승인 요청을 보낸다. - 스킬 부속 파일이 필요 없다면 실체화 없는 buildSkillRegistry(...) 오버로드로 충분하다.

4.5 AgentExecutor 생성 (line 806-808)

final OrcaAgentExecutor agentExecutor = createAgentExecutor(
        effectiveLlmClient, transcriptManager, messageQueueManager,
        config.getCliSettings().isStreaming(),
        skillPreflightScanner, pendingTurnRegistry, memoryContextProvider);

OrcaAgentExecutorFactory가 ReAct 루프 실행기를 만들어준다 — 최종 호출은 create(llmClient, transcriptManager)이고, 나머지는 전부 그 앞의 with* 세터다. 이 인스턴스도 애플리케이션 스코프다 — 모든 세션이 공유한다.

팩토리에는 create() 이전에 이미 몇 가지가 얹혀 있다 (line 795-804): withRewakeService, withSubagentBehaviorRegistry, withCostEstimator, 그리고 트레이싱이 켜져 있으면 withTracer / withTracePayloadPolicy. with*는 팩토리를 변조하고 자기 자신을 돌려주므로, 같은 팩토리 인스턴스로 실행기를 두 번 만들면 두 번째가 첫 번째의 설정을 물려받는다. 실행기를 여러 개 만들 계획이면 팩토리도 따로 만든다.

4.6 SchedulingEngine 생성 (line 809, 862)

final SchedulingEngine schedulingEngine = createSchedulingEngine(agentRuntimeRegistry);
// ...
schedulingEngine.start();  // line 862 — runtime 등록 이후
// AgentSetupFactory.java:1571
private static SchedulingEngine createSchedulingEngine(AgentRuntimeRegistry agentRuntimeRegistry) {
    return SchedulingEngineBuilder.create().agentRuntimeRegistry(agentRuntimeRegistry).build();
}

라이프사이클 규칙 (scope-model.md §2): Scheduling 컴포넌트는 **애플리케이션 수준(long-lived)**이다. AgentRuntime가 소멸돼도 스케줄링 엔진은 유지되어야 한다. CLI는 프로세스 수명이 곧 세션 수명이라 AgentSetup.close()에서 함께 닫지만, 임베딩에서는 분리해야 한다.

SchedulingEngineBuilder.agentRuntimeRegistry(...)의 파라미터에는 @ExternallyManaged가 붙어 있다 — "빌려온 참조이고 엔진이 닫지 않는다"는 뜻이다. 이 애노테이션은 런타임 동작이 없는 문서용 마커지만, 규약상 그 클래스가 닫으면 안 된다는 표시다.

적응 포인트: - 분산/클러스터 스케줄링이 필요하면 aimon-scheduling-quartz의 Quartz 기반 구현으로 교체. - AgentRuntimeRegistry반드시 외부에서 만들어 주입한다 — 엔진이 소유하지 않는다.

4.7 AgentRuntime 조립 (line 833-860)

final OrcaAgentRuntimeFactory agentRuntimeFactory =
    new OrcaAgentRuntimeFactory(
        "1.0.0",
        ".aimon/commands",
        ".aimon/agents",
        ".aimon/skills",
        createWikiKnowledgeStore(agentRuntimeRegistry, llmClient))
        .withSkillRegistry(skillRegistry)
        .withCodeSubagentRegistry(codeSubagentRegistry)
        .withPendingTurnRegistry(pendingTurnRegistry)
        .withAgentApprovalStore(agentApprovalStore)
        .withSessionApprovalStore(sessionApprovalStore)
        .withSkillInvocationPolicy(skillInvocationPolicy)
        .withToolContextEnrichers(toolContextEnrichers)
        .withRewakeService(rewakeService)
        .withWorkflowRunnerEnabled(enableWorkflow || enableWorkflowJs);

// AgentRuntimeId는 인자가 아니다 — createAgentRuntime 내부에서 agent로부터 유도된다.
final OrcaAgentRuntime agentRuntime = createAgentRuntime(
    agentRuntimeFactory, agentExecutor,
    schedulingEngine.getTaskManager(), agentBundle, fileSystem,
    config, graalJsEngines);

configureHooks(agentRuntime, outputFormatter);
final HookHotReloadBootstrap.Started hookHotReload = setupHookHotReload(...);
registerCliTools(agentRuntime, outputFormatter, ...);
configureSchedulingEventListener(schedulingEngine, config);
agentRuntimeRegistry.register(agentRuntime);

AgentRuntimeId를 호출부에서 만들어 넘기지 않는다. createAgentRuntime가 내부에서 AgentRuntimeId.from(agentBundle.getAgent())로 유도한다. 결정론적이라는 것이 핵심이다 — agent:<name> 또는 agent:<name>:<discriminator> 형식으로 고정되므로, 원래 세션이 끝난 한참 뒤에 cron이 재발화해도 ScheduledTask.boundRuntimeId가 같은 runtime으로 resolve 된다. generate() 같은 것은 존재하지 않는다 — 있었다면 바로 그 재발화가 깨졌을 것이다. 필요하면 from(agent) / from(agent, discriminator) / fromName(name) / of(value)를 쓴다.

OrcaAgentRuntimeagent 스코프 객체다 — (Agent, discriminator) 당 하나이며, 그 agent에 대한 모든 세션이 공유한다. 세션마다 만들지 말 것. 안에 다음을 담는다:

  • Agent 정의 + 시스템 프롬프트
  • ToolRegistry (기본 도구 + CLI 도구)
  • SkillRegistry
  • HookRegistry
  • CommandRegistry, SubagentRegistry
  • VirtualFileSystem
  • McpClientManager (MCP 서버가 설정돼 있으면)
  • KnowledgeStore (Wiki 저장소)

기본 도구 제공자들은 OrcaAgentRuntimeFactory.defaultToolProviders()에서 가져온다 — Read, Write, Edit, Bash, Grep, Glob, Todo, Subagent, Skill, Scheduling 등.

적응 포인트: - 자기 도구 추가: agentRuntime.getToolRegistry().register(myTool) (registerCliTools 패턴 참고). - 자기 훅 추가: agentRuntime.getHookRegistry().register(HookEventType.PRE_TOOL, hook) (configureHooks 패턴 참고). 이벤트별 register* 메서드는 없고 타입 토큰 하나로 등록한다. - 도구 일부 비활성화: 커스텀 ToolProvider 리스트를 agentRuntimeFactory.create(...)에 넘긴다.

4.8 LiveSession 생성 (line 865-866)

final LiveSession liveSession = new DefaultLiveSession(
    sessionId,                       // SessionId.of("default") — line 773
    agentRuntime,
    agentExecutor,
    LiveSessionOptions.defaults(),
    messageQueueManager,
    null,                            // HookExecutionManager (OnSessionStart/End 훅) — CLI는 안 씀
    sessionRecordStore);             // 세션의 영속 side field가 사는 곳

DefaultLiveSession은 4·5·6·7-arg 생성자를 제공한다. 뒤쪽 세 개는 각각 MessageQueueManager(mid-turn 큐잉), HookExecutionManager(세션 훅), SessionRecordStore(영속 side field 하이드레이션)를 켜는 스위치이고, 넘기지 않으면 그 기능만 꺼진다. 예를 들어 4-arg를 쓰면 offerAsync가 절대 큐잉하지 않고 항상 SubmitOutcome.Kind.EXECUTED를 돌려준다.

LiveSession이 외부에서 주로 마주칠 진입 API다. submit(input) 호출 한 번이 한 턴(여러 ReAct iteration 포함)을 실행한다. 자세한 사용법은 agent-session-guide.md 참고.

LiveSession은 세션이 아니라 세션의 핸들이다. 영속 애그리게이트는 SessionId로 식별되는 SessionRecord이고, LiveSession은 그 세션에 대해 턴을 실행하는 노드 로컬·일시적 객체다. 관계는 1 : 0..N이다 — 한 세션은 살아 있는 핸들이 0개일 수도(아무도 대화 중이 아님), 시간에 걸쳐 여러 개가 순차적으로 서빙할 수도 있다(idle-TTL 축출, 프로세스 재시작, 노드 간 핸드오프). 이 구분이 §6의 전제다.

4.9 AgentSetup으로 묶어 반환 (line 880-889)

AgentSetup은 CLI 프로세스가 만든 모든 리소스의 핸들이다 — 라이브 세션만이 아니라 agent 스코프와 앱 스코프까지 함께 들고 있다(CLI는 프로세스 = 세션 1개라서 가능한 단순화다). AutoCloseable이므로 try-with-resources로 닫으면 다음 순서로 정리된다 (AgentSetupFactory.java:320-428):

 1. memoryFinalDerivation.run()      // 트랜스크립트가 아직 살아 있을 때 마지막 파생 작업을 큐에 넣는다
 2. memoryQueue.stop()               // 진행 중인 파생 작업 드레인 (의존 저장소보다 먼저)
 3. dreamerSubsystem.close()
 4. memoryMaintenance.close()
 5. liveSession.close()              // 핸들 자원만 정리 — OrcaAgentRuntime은 닫지 않는다
 6. sessionCheckpoints.close()       // liveSession 이후 — 마지막 end-of-turn 저장이 이미 드레인된 뒤
 7. agentRuntime.close()             // 앱 종료이므로 여기서 agent 스코프 자원(MCP 등)을 푼다
 8. graalJsEngines.close()           // runtime 해체 뒤 — 스크립트가 반쯤 닫힌 엔진을 만나지 않도록
 9. agentRuntimeRegistry.unregister(agentRuntime.getId())
10. schedulingEngine.close()         // (CLI 한정 — 임베딩에서는 분리해야 함)
11. rewakeService.close()
12. pendingTurnReaper.close()
13. hookHotReload.close()            // skillHookShell보다 먼저 — reload 콜백이 셸을 쓴다
14. skillHookShell.close()

순서에 이유가 붙은 자리가 네 곳 있고, 전부 코드 주석에 근거가 남아 있다: 파생 작업 → 저장소(2가 3·4보다 먼저), 라이브 세션 → 체크포인트 우편함(5가 6보다 먼저), runtime → GraalJS 엔진(7이 8보다 먼저), 훅 핫리로드 → 셸(13이 14보다 먼저). 자기 셸에서 순서를 바꿀 때 이 네 쌍은 유지한다.

라이브 세션은 닫히지만 그 때문에 runtime이 닫히는 것은 아니다. liveSession.close()OrcaAgentRuntime.close()를 호출하면 안 된다 — 같은 agent의 다른 세션이 아직 그 runtime(MCP 서브프로세스, KnowledgeStore)을 쓰고 있을 수 있다. 위 목록에서 5와 7이 따로 적혀 있는 것이 핵심이다: runtime이 실제로 닫히는 것은 CLI가 프로세스 종료와 동시에 agent를 버리기 때문이지, 세션 종료의 결과가 아니다. 임베딩에서 runtime 해체는 앱 종료 또는 명시적 agent 제거 시 OrcaAgentRuntimeManager.destroyRuntime이 담당한다.


5. 구성 요소별 적응 가이드

컴포넌트 aimon-cli의 선택 자신의 앱에서 흔한 대안
LlmClient OpenAI 또는 Anthropic SDK 래퍼 사내 LLM 게이트웨이를 감싼 자체 구현
VirtualFileSystem LocalFileSystem (jar 디렉터리 기준) GridFSFileSystem / S3FileSystem / 사용자별 격리된 인스턴스
VirtualShell LocalShell 직접 구현 — 컨테이너 격리 은 프레임워크에 없다 (샌드박스 모듈은 도구로 격리한다)
SessionRecordStore InMemorySessionRecordStore aimon-session-mongodb / -postgres / -redis 영속 구현
TranscriptManager DefaultTranscriptManager (+ 백그라운드 체크포인트 우편함) 대개 그대로 — 갈아끼울 것은 그 아래 SessionRecordStore
MessageQueueManager in-memory 분산 큐 백엔드
AgentBundleLoader AdaptiveAgentBundleLoader (클래스패스) 코드/DB에서 만든 AgentBundle 직접 주입
SkillInvocationPolicy 인터랙티브 ASK 정책 rule-based 자동 정책 또는 외부 승인
SchedulingEngine in-memory (디폴트) aimon-scheduling-quartz (분산)
KnowledgeStore WikiKnowledgeStore (파일 기반) aimon-knowledge-opensearch (벡터 검색)
HookRegistry ToolCallDisplayHook, SubagentResultDisplayHook 메트릭/감사 로그/요청-응답 트레이싱 훅
Tool 추가 ConsoleOutputTool 자체 비즈니스 도구 (DB 조회, 사내 API 호출 등)
인터랙션 루프 ReplSession (JLine) HTTP 핸들러 / WebSocket / 배치 잡

도구 작성은 tool-development-guide.md, 훅 작성은 hook-development-guide.md, LLM 어댑터 작성은 llm-provider-development-guide.md를 따른다.


6. 라이프사이클과 스코프

aimon-cli는 단일 프로세스 = 단일 세션이라 모든 것을 한 번에 만들고 한 번에 닫는다. 임베딩에서는 네 스코프를 명확히 분리해야 한다 — CLI를 그대로 베끼면 agent 스코프가 라이브 세션 스코프로 접혀버린다.

전체 규범은 scope-model.md에 있다. 아래는 CLI 코드에 대응시킨 요약이다.

애플리케이션 스코프 (프로세스 수명)

한 번 생성하고 모든 agent와 세션이 공유:

  • LlmClient
  • OrcaAgentExecutor
  • SchedulingEngine + ScheduledTaskManager, RoutineExecutor
  • AgentRuntimeRegistry
  • SessionRecordStore, SessionLeaseStore, TranscriptManager
  • AgentBundleLoader, AgentBundle (변하지 않는 정의라면)
  • MessageQueueManager 인스턴스 풀

Agent 스코프 ((Agent, discriminator) 수명)

agent 당 한 번 생성하고, 그 agent의 모든 세션이 공유한다. 세션이 끝나도 닫지 않는다:

  • OrcaAgentRuntime
  • 그 runtime의 McpClientManager 및 MCP 클라이언트 (서브프로세스는 세션보다 오래 산다)
  • KnowledgeStore (agent 단위로 나눌 경우)
  • runtime 별 ToolRegistry / HookRegistry
  • WorkflowRunner (agent-scoped 변형 — withWorkflowRunnerEnabled로 켰을 때)

생성·조회는 OrcaAgentRuntimeManager.getOrCreateRuntime(bundle, ...)로 한다 — 이름이 말하듯 이미 있으면 재사용한다. 해체는 앱 종료 또는 명시적 agent 제거 시 destroyRuntime으로만.

OrcaAgentRuntime.close()AgentScoped 구현체를 스캔하지 않는다 — 하드코딩된 목록 (mcpClientManager, workflowRunner, ownedShell)만 닫는다. 네이티브 자원(커넥션 풀, 워처 스레드)을 쥔 agent 스코프 컴포넌트를 새로 얹는다면 그 목록에 직접 추가해야 한다. 마커 인터페이스는 문서일 뿐 자동 소멸이 아니다. ownedShell은 셋 중 유일하게 조건부다 — withShell(...)로 셸을 직접 준 어셈블리에서는 null이고, 그때 셸을 닫는 것은 준 쪽의 몫이다.

세션 스코프 (SessionId 수명 — 영속)

세션이 존재하는 동안 유지되고, 재시작·축출·노드 이동을 넘어 살아남는다:

  • SessionRecord (메시지 이력)
  • SessionTotals, budgetOverride — 레코드의 side field
  • SessionTranscript

이 값들이 LiveSession이 아니라 레코드 쪽에 있는 것이 요점이다. 라이브 세션은 뒤의 두 개를 SessionRecordStore.setTotalsAndBudgetOverride로 한 쌍씩 되쓴다.

라이브 세션 스코프 (한 접속의 수명 — 노드 로컬)

핸들마다 생성, close()로 정리:

  • LiveSession
  • 메시지 큐 구독과 이벤트 publisher
  • 그 핸들이 만든 턴 추적 상태

LiveSession노드 로컬·일시적 핸들이다. 한 세션(SessionId)은 살아 있는 핸들이 0개일 수도, 시간에 걸쳐 여러 개일 수도 있다(idle-TTL 축출, 재시작, 노드 이동). 재시작을 넘어 살아남아야 하는 값은 핸들이 아니라 SessionRecord 쪽에 둔다.

새 타입 이름을 지을 때: 영속되어야 하면 Session* (at.aimon.core.agent.session[.store|.transcript]), 프로세스와 함께 사라져도 되면 LiveSession* (at.aimon.core.agent.session), 에이전트 단위로 한 번 모으면 되면 Agent* (at.aimon.core.agent). 맨 단어 SessionAgentSession은 타입 이름으로 금지되어 있고 SessionNamingArchitectureTest가 빌드에서 막는다 — 그 두 이름이 정확히 위 두 수명을 서로 사칭하게 만들기 때문이다. 반면 "conversation"은 여전히 유효한 단어이며 LLM과의 메시지 교환을 뜻한다 (getConversationHistory(), /compact의 "Conversation compacted"). 수명을 뜻하는 데 쓰지 않는다.

이름의 마지막 명사로 수명을 추론하지 말 것. *Store / *Registry / *Manager / *Factory는 X를 관리하는 컨테이너이고, 컨테이너 자신의 수명은 X의 수명이 아니다 — SessionRecordStore는 항목이 세션 단위지만 인스턴스는 애플리케이션 스코프이고, AgentRuntimeRegistry도 마찬가지다. 판단은 이름이 아니라 무엇으로 키잉되는가로 한다: Map<AgentRuntimeId, _>면 agent-scoped, Map<SessionId, _>면 session-scoped.

잘못된 패턴

// 안 됨 (1): 라이브 세션 close()에서 SchedulingEngine까지 닫으면
//           다른 세션의 예약 작업이 모두 죽는다
try (AgentSetup setup = factory.create(config)) {
    // ...
}
// → setup.close() 안에서 schedulingEngine.close()가 호출됨 (CLI 가정)

// 안 됨 (2): 세션마다 runtime을 새로 만들고 세션 close()에서 닫기
OrcaAgentRuntime rt = factory.create(...);   // 세션마다 MCP 서브프로세스 재기동
liveSession.close();
rt.close();   // 같은 agent의 다른 세션이 쓰던 MCP/KnowledgeStore를 끊어버린다

// 안 됨 (3): 세션 누적치를 라이브 세션 안에 들고 있기
//           핸들이 축출되거나 노드가 바뀌면 조용히 사라진다. SessionRecord에 둔다.

임베딩 패턴

// 애플리케이션 시작 시 한 번
SchedulingEngine engine = SchedulingEngineBuilder.create()
    .agentRuntimeRegistry(registry).build();
engine.start();
LlmClient llmClient = new OpenAILlmClient(openAiConfig);
OrcaAgentExecutor executor = ...;
SessionRecordStore sessionRecords = ...;   // 앱 스코프. 재시작을 넘어야 하면 영속 구현

// agent 당 한 번 (이미 있으면 재사용된다)
OrcaAgentRuntime runtime = runtimeManager.getOrCreateRuntime(agentBundle, ...);

// 접속마다 — runtime은 만들지도, 닫지도 않는다
LiveSession session = new DefaultLiveSession(
    SessionId.of(userId), runtime, executor, LiveSessionOptions.defaults(),
    queueManager, null, sessionRecords);
try {
    AgentExecutionResult result = session.submit(input);   // 동기 — 한 턴이 끝나면 돌아온다
} finally {
    session.close();              // 핸들 자원만 정리, runtime은 그대로 산다
}

// 애플리케이션 종료 시 한 번
runtimeManager.destroyRuntime(runtime.getId());   // 여기서 비로소 MCP/KnowledgeStore 해제
engine.close();

자세한 임베딩 패턴은 embedding-agent-in-application.md 참고.


7. 최소 임베딩 예제

aimon-cli의 부트스트랩을 가장 단순한 형태로 압축한 예제. 실제로 동작하는 최소 코드.

import at.aimon.cli.config.CliConfig;
import at.aimon.cli.config.CliConfigLoader;
import at.aimon.cli.factory.AgentSetupFactory;
import at.aimon.cli.factory.AgentSetupFactory.AgentSetup;
import at.aimon.core.agent.AgentExecutionResult;
import at.aimon.core.agent.session.LiveSession;

public class MyEmbeddedAgent {
    public static void main(String[] args) throws Exception {
        // 1. 설정 로드 (자체 YAML 또는 코드 빌드)
        CliConfig config = new CliConfigLoader().loadDefault();

        // 2. AgentSetup 한 번만 생성 (애플리케이션 스코프)
        try (AgentSetup setup = new AgentSetupFactory().create(config)) {

            // 3. 라이브 세션을 통해 입력 제출.
            //    submit(...)은 동기다 — 한 턴이 끝날 때까지 블록하고 결과를 돌려준다.
            LiveSession session = setup.getLiveSession();
            AgentExecutionResult result =
                session.submit("Read the README.md and summarize it");

            // 4. 결과 사용
            if (result.isSuccess()) {
                System.out.println(result.getFinalAnswer());
            } else {
                System.err.println(result.getErrorMessage());
            }
        }
    }
}

이벤트를 받으면서 비동기로 돌리고 싶으면 submitAsync(input, listener)CompletionStage<AgentExecutionResult>를 돌려준다 — 위의 동기 submit과 짝을 이루는 쪽이 그것이다.

CompletionStage<AgentExecutionResult> stage = session.submitAsync(
    "Read the README.md and summarize it",
    event -> System.out.println(event));   // 토큰 델타, 도구 호출, iteration 진행 ...
AgentExecutionResult result = stage.toCompletableFuture().get();

더 적극적인 임베딩 (자기 컴포넌트로 갈아끼우기)이 필요하면 AgentSetupFactorycreate() 본문을 자기 컴포지션 루트로 옮겨 단계별로 자기 구현체를 주입한다. 위의 4. AgentSetupFactory.create()를 한 줄씩 섹션을 그대로 참고하면 된다.


8. 웹 애플리케이션으로 옮기기

aimon-cliAgentSetupFactory.create()는 앱·agent·세션 스코프를 한 덩어리로 만든다 — 단일 사용자 / 단일 프로세스 가정이라 가능한 일이다. 웹에서는 이것들을 분리해서 앱 스코프 빈은 한 번만, agent 스코프 runtime은 agent 당 한 번, 라이브 세션만 사용자 접속마다 만들어야 한다. 이 섹션은 그 분리를 네 단계로 보여준다.

  1. 컴포넌트 스코프 분리표
  2. Spring Boot 컴포지션 루트
  3. LiveSession ↔ HTTP/SSE 어댑터
  4. 비대화형 스킬 승인 채널

이 섹션은 직접 조립하는 경로를 보여준다. 멀티 노드 배포에서 세션 라우팅·리스·핸드오프까지 필요하면 aimon-session-routingSessionRouter(SessionRouter.builder())가 그 계층을 이미 구현해 두었다 — 운영 관점의 설정은 web-session-deployment-guide.md를 본다.

8.1 컴포넌트 스코프 분리표

컴포넌트 스코프 빈 종류 비고
LlmClient @Bean(destroyMethod = "close") SDK 커넥션 풀이 안에 있음. 모든 사용자가 공유
OrcaAgentExecutor @Bean 싱글톤 stateless. 모든 세션이 공유
SchedulingEngine @Bean(initMethod = "start", destroyMethod = "close") 세션 close()와 절대 묶지 말 것
AgentRuntimeRegistry @Bean 싱글톤 SchedulingEngine이 lazy lookup용으로 사용
AgentBundleLoader, AgentBundle @Bean 싱글톤 정의가 정적이면 한 번만 로드
PendingTurnReaper @Bean(initMethod = "start", destroyMethod = "close") 데몬 스레드 한 개로 충분
LocalShell (skill 훅용) @Bean(destroyMethod = "close") I/O 스레드풀 공유
SessionRecordStore @Bean 싱글톤 항목은 세션 단위, 인스턴스는 앱 스코프. 멀티 인스턴스에서는 Mongo/Postgres/Redis 영속 구현 필수
TranscriptManager @Bean 싱글톤 위 저장소를 감싼 디폴트 구현
PendingTurnRegistry, AgentApprovalStore, SessionApprovalStore @Bean 싱글톤 항목의 키는 각각 pending turn / AgentRuntimeId / SessionId 지만 인스턴스는 앱 스코프다. 클러스터에서 라우팅이 안 보장되면 분산 백엔드로
OrcaAgentRuntime agent OrcaAgentRuntimeManager.getOrCreateRuntime() (Agent, discriminator) 당 하나. MCP, KnowledgeStore 소유. 라이브 세션 close()로 닫지 말 것
OrcaAgentRuntimeManager @Bean 싱글톤 agent 스코프 runtime의 생성·캐시·해체를 소유
MessageQueueManager 라이브 세션 팩토리에서 매번 생성 producer(HTTP)와 consumer(executor)가 같은 인스턴스를 공유해야 함
LiveSession 라이브 세션 팩토리에서 매번 생성 SessionId 당 동시에 0..1개, 시간에 걸쳐 N개
VirtualFileSystem 사용자/agent 사용자별 분리 권장 GridFS bucket / S3 prefix 분리

가장 흔한 실수 네 가지: - OrcaAgentExecutor/LlmClient를 접속마다 새로 만든다 — 비싸고 의미 없다. - MessageQueueManager를 앱 스코프 싱글톤으로 만든다 — 다른 사용자의 mid-turn 입력이 새어들어간다. - OrcaAgentRuntime을 라이브 세션 스코프로 만든다 — 접속마다 MCP 서브프로세스가 재기동되고, 핸들 하나가 닫힐 때 같은 agent의 다른 세션이 쓰던 MCP·KnowledgeStore가 함께 끊긴다. VirtualFileSystem을 사용자별로 나눠야 한다면 사용자를 discriminator로 넘겨 getOrCreateRuntime(bundle, userId, fs, store)를 쓴다 — 접속마다 새로 만드는 것이 아니라 사용자마다 하나다. - 재시작을 넘어야 하는 값을 LiveSession 안에 들고 있는다 — 누적 토큰/비용, 예산 오버라이드 같은 값은 SessionRecordStore의 레코드에 있어야 한다. 핸들 안에 두면 idle-TTL 축출 한 번에 조용히 사라진다.

8.2 Spring Boot 컴포지션 루트

앱 스코프 — @Configuration

@Configuration
public class AimonAppConfig {

    @Bean(destroyMethod = "close")
    public LlmClient llmClient(@Value("${aimon.openai.key}") String apiKey,
                               @Value("${aimon.openai.model:gpt-5.1}") String model) {
        return new OpenAILlmClient(OpenAIConfig.builder()
            .apiKey(apiKey)
            .model(model)
            .timeout(Duration.ofSeconds(60))
            .build());
    }

    @Bean
    public AgentRuntimeRegistry agentRuntimeRegistry() {
        return new DefaultAgentRuntimeRegistry();
    }

    @Bean(initMethod = "start", destroyMethod = "close")
    public SchedulingEngine schedulingEngine(AgentRuntimeRegistry registry) {
        return SchedulingEngineBuilder.create().agentRuntimeRegistry(registry).build();
    }

    @Bean
    public SessionRecordStore sessionRecordStore() {
        // 멀티 인스턴스 환경에서는 aimon-session-mongodb / -postgres / -redis 구현으로 교체.
        // 인스턴스는 앱 스코프이고 항목만 SessionId로 갈린다.
        return new InMemorySessionRecordStore();
    }

    @Bean(destroyMethod = "close")
    public SessionCheckpointMailbox sessionCheckpoints() {
        // 턴 중간 크래시에도 append된 메시지를 잃지 않도록 자기 스레드에서 비동기로 흘려보낸다.
        return SessionCheckpointMailbox.background();
    }

    @Bean
    public TranscriptManager transcriptManager(SessionRecordStore store,
                                               SessionCheckpointMailbox checkpoints) {
        return new DefaultTranscriptManager(store, checkpoints);
    }

    @Bean
    public OrcaAgentExecutor agentExecutor(LlmClient llmClient,
                                           TranscriptManager transcriptManager) {
        return new OrcaAgentExecutorFactory()
            .withUseStreaming(true)
            .create(llmClient, transcriptManager);
    }

    @Bean(destroyMethod = "close")
    public VirtualShell skillHookShell() {
        return new LocalShell();
    }

    @Bean
    public SkillParser skillParser(VirtualShell skillHookShell) {
        return new MarkdownSkillParser(
            new ShellArgumentTokenizer(),
            new SkillHookSetParser(new DefaultShellActionExecutor(skillHookShell)));
    }

    @Bean
    public AgentBundle defaultAgentBundle(SkillParser skillParser) {
        return new AdaptiveAgentBundleLoader(
            "agents", new MarkdownAgentDefinitionParser(),
            getClass().getClassLoader(), skillParser).load("default");
    }

    @Bean
    public PendingTurnRegistry pendingTurnRegistry() {
        return new InMemoryPendingTurnRegistry();
    }

    @Bean(initMethod = "start", destroyMethod = "close")
    public PendingTurnReaper pendingTurnReaper(PendingTurnRegistry registry) {
        return PendingTurnReaper.builder()
            .registry(registry)
            .interval(Duration.ofSeconds(60))
            .expirationListener(turns -> { /* 메트릭/로그 */ })
            .build();
    }

    @Bean
    public SessionApprovalStore sessionApprovalStore() {
        // at.aimon.core.skill.policy.session — SessionId로 키잉되는 좁은 쪽
        return new InMemorySessionApprovalStore();
    }

    @Bean
    public AgentApprovalStore agentApprovalStore() {
        // at.aimon.core.skill.policy.agent — AgentRuntimeId로 키잉되는 넓은 쪽
        return new InMemoryAgentApprovalStore();
    }

    @Bean
    public SkillInvocationPolicy skillInvocationPolicy(
            SessionApprovalStore sessionApprovals, AgentApprovalStore agentApprovals) {
        // 8.4 참고 — 자동 정책 또는 ASK + suspend/resume.
        // 좁은 것부터: 세션 승인 → 에이전트 전역 승인 → 규칙. 이 순서를 뒤집으면
        // 세션 단위 거부가 영영 도달하지 못한다.
        return new SessionScopedSkillInvocationPolicy(sessionApprovals,
            new ApprovalCachingSkillInvocationPolicy(agentApprovals,
                RuleBasedSkillInvocationPolicy.builder()
                    .defaultDecision(SkillInvocationDecision.ASK).build()));
    }
}

Agent 스코프 runtime — 사용자마다 한 번 (요청마다가 아니다)

OrcaAgentRuntimeManager는 앱 스코프 싱글톤 빈이다. getOrCreateRuntime()은 이름 그대로 캐시 조회 후 없을 때만 생성하며, 레지스트리 등록까지 내부에서 처리한다 — 호출부가 register()를 따로 부를 필요가 없다. 사용자별 VFS 분리가 필요하면 접속마다 새 runtime을 만드는 것이 아니라 userId를 discriminator로 넘긴다.

@Bean
public OrcaAgentRuntimeManager agentRuntimeManager(
        OrcaAgentExecutor executor, AgentRuntimeRegistry registry,
        SchedulingEngine schedulingEngine,
        SkillInvocationPolicy skillPolicy, SessionApprovalStore sessionApprovals,
        AgentApprovalStore agentApprovals, PendingTurnRegistry pendingTurnRegistry) {

    // withSkillRegistry()는 일부러 부르지 않는다 — 사용자마다 VFS가 다르므로 스킬 레지스트리도
    // runtime 별로 달라야 한다. 생략하면 팩토리가 (agentBundle, fileSystem)에서 runtime마다 새로 만든다.
    OrcaAgentRuntimeFactory runtimeFactory =
        new OrcaAgentRuntimeFactory("1.0.0",
            ".aimon/commands", ".aimon/agents", ".aimon/skills",
            /* knowledgeStore */ null)
            .withSessionApprovalStore(sessionApprovals)
            .withAgentApprovalStore(agentApprovals)
            .withPendingTurnRegistry(pendingTurnRegistry)
            .withSkillInvocationPolicy(skillPolicy);

    return OrcaAgentRuntimeManager.builder()
        .agentExecutor(executor)
        .agentRuntimeRegistry(registry)
        .agentRuntimeFactory(runtimeFactory)
        .scheduledTaskManager(schedulingEngine.getTaskManager())
        .toolProviders(OrcaAgentRuntimeFactory.defaultToolProviders())
        .commandProviders(OrcaAgentRuntimeFactory.defaultCommandProviders())
        .build();
}

라이브 세션 팩토리 — 접속마다 새 핸들 (runtime은 재사용)

@Component
public class WebLiveSessionOpener {

    private final OrcaAgentExecutor executor;
    private final OrcaAgentRuntimeManager runtimeManager;
    private final AgentBundle agentBundle;
    private final CredentialStore credentialStore;
    private final SessionRecordStore sessionRecords;   // 앱 스코프 — 주입만 받는다
    private final VirtualFileSystemProvider fsProvider;

    // 생성자 주입 생략

    public LiveSession openFor(String userId, SessionId sessionId) {
        VirtualFileSystem userFs = fsProvider.forUser(userId);  // 사용자별 GridFS bucket / S3 prefix

        // agent 스코프: userId 당 한 번만 실제로 생성된다. 두 번째 세션부터는 캐시된 인스턴스가
        // 그대로 반환되므로 MCP 서브프로세스도 재기동되지 않는다.
        OrcaAgentRuntime runtime =
            runtimeManager.getOrCreateRuntime(agentBundle, userId, userFs, credentialStore);

        // 라이브 세션 스코프: 핸들마다 새로. producer(HTTP)와 consumer(executor)가 같은 인스턴스를 봐야 한다.
        MessageQueueManager queueManager = new DefaultMessageQueueManager(
            new InMemoryMessageQueueRepository());

        // 마지막 인자가 SessionRecordStore 다. 넘기면 세션 누적치(SessionTotals)와 budgetOverride 를
        // 열 때 복원하고 턴이 끝날 때마다 되쓴다. 넘기지 않으면(null) 이 핸들이 닫히는 순간
        // 그 값들은 사라진다 — 재시작·축출을 넘겨야 하는 값이므로 웹에서는 반드시 넘긴다.
        return new DefaultLiveSession(sessionId, runtime, executor,
            LiveSessionOptions.defaults(), queueManager, /* hookExecutionManager */ null, sessionRecords);
    }

    public void close(LiveSession session) {
        session.close();   // 라이브 세션 스코프만 정리한다
        // runtime은 unregister/close 하지 않는다 — 같은 사용자의 다른 세션이 아직 쓰고 있다.
        // SchedulingEngine도 절대 닫지 않는다 — @PreDestroy가 책임.
    }
}

사용자가 로그아웃하거나 유휴 사용자를 회수할 때에만 runtimeManager.destroyRuntime(AgentRuntimeId.from(agent, userId))를 호출한다. 그 사용자의 모든 라이브 세션이 닫힌 뒤여야 한다. 여기서 id를 AgentRuntimeId.from(agent, userId)다시 계산해서 넘길 수 있는 이유는 그 발급이 결정론적이기 때문이다 — generate() 같은 것은 없다.

8.3 LiveSession ↔ HTTP/SSE 어댑터

웹 클라이언트는 메시지 한 번에 — 토큰 델타, Tool 호출, iteration 진행, 완료 — 여러 이벤트를 받아야 한다. LiveSession.submitAsync(input, listener)가 이걸 정확히 지원한다 (LiveSession.java:162).

라이브 세션 보관소

@Component
public class LiveSessionRegistry {

    private final Map<SessionId, LiveSession> live = new ConcurrentHashMap<>();
    private final WebLiveSessionOpener opener;

    public LiveSession getOrOpen(String userId, SessionId sessionId) {
        return live.computeIfAbsent(sessionId, id -> opener.openFor(userId, id));
    }

    public Optional<LiveSession> peek(SessionId sessionId) {
        return Optional.ofNullable(live.get(sessionId));
    }

    public void close(SessionId sessionId) {
        LiveSession session = live.remove(sessionId);
        if (session != null) {
            opener.close(session);   // 핸들만 닫힌다. runtime도 세션 레코드도 살아 있다
        }
    }
}

운영에서는 TTL eviction(예: 30분 idle), 사용자당 최대 핸들 수, 인스턴스 셧다운 시 일괄 close 같은 정책을 추가한다. Caffeine/Guava cache의 removalListener에 opener.close()를 묶는 패턴이 흔하다. 이 축출 정책은 라이브 세션에만 적용된다 — 핸들이 TTL로 사라져도 agent 스코프 runtime은 그대로 남아, 같은 사용자가 다시 접속하면 MCP 재기동 없이 즉시 이어간다. runtime 회수가 필요하면 별도의(훨씬 긴) 유휴 정책으로 destroyRuntime을 건다.

이 맵의 키가 SessionId인데 값의 수명은 그보다 짧다는 점이 이 절 전체의 요지다. SessionId에 대해 살아 있는 핸들은 동시에 0..1개, 시간에 걸쳐서는 N개다 — 축출·재접속·프로세스 재시작마다 새 핸들이 같은 세션을 이어받는다. 따라서 누적 토큰·비용이나 예산 오버라이드처럼 그 이어받기를 넘어야 하는 값은 이 맵이 아니라 SessionRecordStore의 레코드에 있어야 한다(8.2의 WebLiveSessionOpener가 마지막 인자로 넘기는 그 저장소다).

SSE 컨트롤러 (Spring WebMVC)

@RestController
@RequestMapping("/agent/sessions/{sessionId}")
public class AgentChatController {

    private final LiveSessionRegistry sessions;

    @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter sendMessage(@PathVariable String sessionId,
                                  @AuthenticationPrincipal Principal user,
                                  @RequestBody MessageRequest body) {

        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(5).toMillis());
        LiveSession session = sessions.getOrOpen(user.getName(), SessionId.of(sessionId));

        // offerAsync는 세션이 이미 turn을 돌리고 있으면 mid-turn 큐에 쌓는다
        SubmitOutcome outcome = session.offerAsync(body.text(), event -> {
            try {
                emitter.send(SseEmitter.event()
                    .name(event.getClass().getSimpleName())
                    .data(EventDto.from(event)));   // 자기 DTO로 직렬화
            } catch (IOException ignored) {
                // 클라이언트가 끊으면 다음 이벤트에서 다시 실패함 — 그때 cleanup
            }
        });

        if (outcome.getKind() == SubmitOutcome.Kind.QUEUED) {
            try {
                emitter.send(SseEmitter.event().name("queued")
                    .data("Session busy, queued at position " + outcome.getQueuePosition()));
            } catch (IOException ignored) {}
        }

        // 지금 시작된 턴의 id를 클라이언트에 흘려보낸다 — 아래 /interrupt가 이걸 되돌려 받는다.
        // EXECUTED 일 때만이다: QUEUED 였다면 지금 돌고 있는 턴은 남의 턴이고, 그 id를 이 응답의
        // 턴이라고 알려주면 사용자가 "중지"를 눌렀을 때 무고한 턴을 끊게 된다.
        // 이것도 best-effort 값이므로(§동시성 주의) 비어 있으면 그냥 보내지 않는다.
        if (outcome.getKind() == SubmitOutcome.Kind.EXECUTED) {
            session.currentTurnId().ifPresent(turnId -> {
                try {
                    emitter.send(SseEmitter.event().name("turn").data(turnId.value()));
                } catch (IOException ignored) {}
            });
        }

        // getResultStage()는 Optional 이다 — QUEUED 결과에는 아직 붙일 스테이지가 없다.
        // 큐에 쌓인 입력의 결과는 세션이 그 턴을 실제로 시작할 때 이벤트 스트림으로 온다.
        outcome.getResultStage().ifPresent(stage -> stage.whenComplete((result, ex) -> {
            try {
                if (ex != null) {
                    emitter.completeWithError(ex);
                } else {
                    emitter.send(SseEmitter.event().name("done")
                        .data(ResultDto.from(result)));
                    emitter.complete();
                }
            } catch (IOException e) {
                emitter.completeWithError(e);
            }
        }));

        return emitter;
    }

    @PostMapping("/interrupt")
    public ResponseEntity<Void> interrupt(@PathVariable String sessionId,
                                          @RequestBody InterruptRequest body) {
        // 주소 지정 형태를 쓴다. 무인자 interrupt(reason)는 "그 순간 돌고 있는 턴"을 끊으므로
        // 축출/셧다운/리스 상실 같은 관리 목적에는 맞지만, "내가 보낸 턴을 멈춰줘"에는 틀리다 —
        // 사용자의 클릭이 도착하기 전에 그 턴이 끝나고 다음 턴이 시작됐을 수 있다.
        // turnId를 넘기면 불일치가 조용한 no-op이 되어 무고한 턴을 끊지 않는다.
        sessions.peek(SessionId.of(sessionId)).ifPresent(session ->
            session.interrupt(TurnId.of(body.turnId()), InterruptReason.USER_SIGINT));
        return ResponseEntity.accepted().build();
    }

    @DeleteMapping
    public ResponseEntity<Void> close(@PathVariable String sessionId) {
        sessions.close(SessionId.of(sessionId));   // 핸들만 닫는다 — 세션 레코드는 남는다
        return ResponseEntity.noContent().build();
    }
}

InterruptReasonenum이다 — 자유 문자열 사유를 담는 팩토리는 없다. 웹의 "중지" 버튼은 USER_SIGINT("SIGINT on the CLI host, or equivalent")가 맞고, 관리 목적에는 SESSION_RELEASED / SYSTEM_SHUTDOWN / LEASE_LOST / HOLDER_LOST가 따로 있다. 인터럽트가 실제로 꽂혔는지는 이 호출로 알 수 없다(본질적으로 racy) — 턴의 종료 이벤트로 관찰한다.

동시성 주의

  • LiveSession스레드 안전 보장이 없다 (LiveSession.java:42-47). 한 핸들은 한 번에 한 턴만 실행해야 한다.
  • 같은 SessionId에 동시 요청이 들어오면 submitAsync 대신 offerAsync를 써서 mid-turn 큐에 쌓이게 한다. SubmitOutcome.getKind()로 즉시 실행(EXECUTED)/대기(QUEUED)를 확인한다.
  • status()currentTurnId()는 제어 게이트가 아니다. 둘 다 동기화 없이 읽는 best-effort 관찰값이라 정착 중인 턴에 대해 잠깐 어긋날 수 있다. "턴을 시작해도 되는가"는 offerAsync가 돌려주는 SubmitOutcome으로만 판단한다.
  • 인터럽트는 동기 호출이 아니다 — 한 턴에 대해 첫 트립만 의미가 있고(이후 호출은 멱등 no-op), 실제 중단은 다음 ReAct iteration 또는 도구 종료 시점에 반영된다. 활성 턴이 없으면 조용한 no-op이며 예외를 던지지 않는다.
  • 멀티 인스턴스에서는 두 가지 길이 있다:
  • 세션 어피니티 라우팅: SessionId → 인스턴스 매핑 (sticky session, gateway 라우팅 룰). 리스·핸드오프까지 필요하면 직접 짜지 말고 SessionRouter를 쓴다.
  • 핸들 stateless화: LiveSession을 매 요청마다 재생성하고 모든 상태를 SessionRecordStore에 넘긴다. 단, mid-turn 인터럽트/큐잉 같은 인메모리 상태가 사라지므로 trade-off 분명히.

8.4 비대화형 스킬 승인 채널

SkillApprovalChannel동기 인터페이스다 (SkillApprovalChannel.java 의 "Stay synchronous" 계약):

"Stay synchronous. The scanner blocks on this call. Implementations that genuinely need async resolution should not implement this interface; they should let the suspend/resume path run instead."

웹 환경에서는 이 동기 계약 때문에 두 가지 길로 갈라진다.

옵션 A — 룰 기반 자동 결정 (자동화 워크플로)

자체 정책으로 즉시 결정 가능하면 동기 채널이 깔끔하다.

public class PolicyBasedApprovalChannel implements SkillApprovalChannel {

    private final SessionApprovalStore sessionApprovals;   // 좁은 쪽 — SessionId 로 키잉
    private final AgentApprovalStore agentApprovals;       // 넓은 쪽 — AgentRuntimeId 로 키잉
    private final SkillPolicyEvaluator evaluator;

    // 2-arg 가 인터페이스의 추상 메서드다. 세션을 아는 구현은 3-arg 쪽을 override 하고,
    // 2-arg 는 "세션이 없는 호출"로 위임만 시킨다.
    @Override
    public void requestApproval(List<PendingSkillRequest> pendingRequests,
                                AgentRuntimeId agentRuntimeId) {
        requestApproval(pendingRequests, agentRuntimeId, null);
    }

    @Override
    public void requestApproval(List<PendingSkillRequest> pendingRequests,
                                AgentRuntimeId agentRuntimeId, SessionId sessionId) {
        for (PendingSkillRequest req : pendingRequests) {
            // 절대 throw 금지 — 실패 시 안전 기본값(DENY)으로 기록 (SkillApprovalChannel 의 "Never throw" 계약)
            SkillInvocationDecision decision;
            try {
                decision = evaluator.evaluate(req.getSkillName(), req.getArgs());
            } catch (Exception e) {
                decision = SkillInvocationDecision.DENY;
            }
            // 스캐너는 채널의 반환값을 읽지 않는다. 반드시 정책 체인이 읽는 저장소에 써야 하고,
            // 쓰지 않은 스킬은 다음 체크에서 그냥 다시 ASK 가 된다.
            if (sessionId != null) {
                sessionApprovals.put(sessionId, req.getSkillName(), decision);
            } else {
                // sessionId 가 null 인 호출이 실제로 있다 — 스케줄 태스크처럼 사용자가 시킨 턴이 아닌 실행.
                // 이때 조용히 버리면 안 된다. 넓은 쪽에라도 기록한다.
                agentApprovals.put(agentRuntimeId, req.getSkillName(), decision);
            }
        }
    }
}

IMPORTANT — AgentApprovalStore 에 넣은 승인은 만료되지 않는다: 키가 AgentRuntimeId (agent:<name>[:<discriminator>]) 이므로 여기에 기록한 결정은 그 에이전트의 이후 모든 세션에 그대로 적용되고, TTL 이 없으며 /clear 로도 지워지지 않는다. 이 저장소는 사용자가 "이 에이전트에서 항상 허용"이라고 명시적으로 답한 경우에만 쓴다 — 사용자는 자기 답이 닿을 다른 세션들을 볼 수 없으므로, 평범한 "예" 를 이 스코프로 승격시켜서는 안 된다. "이번 세션에서만 허용"에는 세션 단위인 SessionApprovalStore (at.aimon.core.skill.policy.session) 를 쓰고, SessionScopedSkillInvocationPolicy 로 감싸면 정책 체인이 그쪽을 먼저 본다 (8.2 의 체인 참고). 되돌리는 경로는 각 저장소의 invalidate(...) 이며, CLI 는 이를 /revoke (세션) 와 /revoke --agent (에이전트 전역) 로 노출한다 — 웹 UI 를 만든다면 동등한 취소 버튼을 반드시 함께 제공해야 한다.

세션 단위 승인의 도달 범위는 그 세션과 그 세션이 위임한 실행(서브에이전트 포크·스킬 포크·포그라운드 워크플로)이다. 다만 그 도달 방식을 오해하기 쉽다 — 포크는 자기 SessionId 를 갖지 않는다. DefaultSubagentExecutor 는 툴 컨텍스트에 SESSION_ID 를 아예 넣지 않고, 실행 정체성인 ExecutionIdEXECUTION_ID 로, 그리고 자기를 띄운 사용자 세션의 id 를 INVOKING_SESSION_ID 로 공개한다. 정책은 후자로 답을 찾는다. 포크가 다시 포크를 띄워도 중간 포크가 아니라 사용자의 세션 id 가 그대로 전달된다. 포크는 사람에게 물을 채널이 없으므로 — 채널이 포크에서 도달 가능해서도 안 된다, 사용자가 그 화면을 보고 있지 않다 — 이 경로가 없으면 포크의 스킬 호출이 전부 막힌다.

두 id 는 축이 다르다: sessionId수명(내 세션이 무엇인가), invokingSessionId도달 범위(누구의 결정이 나에게 적용되는가). 그리고 와이어 키는 여전히 "conversationId" / "invokingConversationId" — Java 식별자만 개명됐고 직렬화 이름은 호환성을 위해 의도적으로 동결되어 있다. 저장된 이름과 타입 이름이 어긋나 보이는 것이 정상이다.

이름이 재사용된 자리라 특히 주의할 것: SessionApprovalStore 라는 이름은 예전에 에이전트 전역 저장소의 이름이었다(키가 AgentRuntimeId 인데 이름이 세션을 말하고 있었다). 그것은 지금 AgentApprovalStore 이고, 비어 있던 그 이름은 진짜 세션 단위 저장소에 다시 붙었다. 옛 코드나 옛 문서에서 SessionApprovalStore 를 봤다면 지금의 AgentApprovalStore 일 수 있다 — 패키지(…policy.agent vs …policy.session)와 키 타입으로 구분한다. 옛 ConversationApprovalStore 가 지금의 SessionApprovalStore, 옛 ConversationAwareSkillInvocationPolicy 가 지금의 SessionScopedSkillInvocationPolicy 다.

규칙이 단순하면 채널 자체를 두지 않고 RuleBasedSkillInvocationPolicy만으로 끝나는 경우가 많다 — 정책이 ASK 대신 ALLOW/DENY를 직접 반환하면 채널이 호출되지도 않는다. 룰은 임의 람다가 아니라 스킬 이름 글로브 패턴이며, 평가 순서는 deny → allow → safe-by-default → defaultDecision 이다.

SkillInvocationPolicy autoPolicy = RuleBasedSkillInvocationPolicy.builder()
    .addDenyPattern("dangerous-*")          // 가장 높은 우선순위
    .addAllowPattern("report-*")
    .safeByDefault(false)                   // 디폴트는 true — 끄면 ALLOW 는 명시적 allow 패턴으로만
    .defaultDecision(SkillInvocationDecision.DENY)  // 아무 룰도 안 걸렸을 때. 디폴트도 DENY(fail-closed)
    .build();

defaultDecision 의 기본값은 DENY 다 — CLI 가 ASK 를 쓰는 것은 대화형 셸에 물어볼 사람이 있기 때문이지 그것이 프레임워크 기본값이어서가 아니다. 무인 워크플로에서 ASK 로 두면 채널이 없는 한 옵션 B 의 suspend 경로로 떨어져 턴이 멈춘다.

옵션 B — 외부 승인 (사람이 결정해야 하는 경우)

사람의 클릭이 필요한 흐름은 동기 채널을 만들지 말고, scanner의 fallback인 suspend/resume 경로를 쓴다. aimon-cli/approve, /deny, /pending 명령과 같은 메커니즘 — 단지 입력 채널이 터미널에서 HTTP로 바뀔 뿐이다.

흐름:

  1. Scanner가 채널이 없으면 (또는 DENY로 폴백) → 턴이 PendingTurnRegistry에 등록되며 일시 중단
  2. 클라이언트는 events() 스트림에서 보류 이벤트를 수신
  3. 사용자가 승인/거절을 별도 UI에서 결정
  4. 승인 결과를 백엔드 API로 전달
  5. 컨트롤러는 결정을 스코프에 맞는 저장소(SessionApprovalStore 또는 AgentApprovalStore)에 기록한 뒤 pendingTurnRegistry.remove(turnId) 로 보류 항목 제거
  6. 클라이언트가 같은 프롬프트를 다시 제출 → Scanner가 정책을 다시 묻고 이번엔 캐시된 ALLOW/DENY로 결정 → 턴 진행

5–6 단계 주의: resume(turnId) 같은 API 는 없다. PendingTurnRegistry 는 순수 저장소이고 실행을 재개시키지 않는다 — 승인 기록 + 항목 제거까지가 서버 몫이고, 실제 재실행은 클라이언트가 턴을 다시 제출해서 일어난다 (CLI 의 /approve 도 "Resume the agent to continue" 라고 안내할 뿐 스스로 재개하지 않는다).

@RestController
@RequestMapping("/agent/pending/{turnId}")
public class PendingApprovalController {

    private final PendingTurnRegistry pendingTurns;
    private final SessionApprovalStore sessionApprovals;
    private final AgentApprovalStore agentApprovals;

    @PostMapping("/decide")
    public ResponseEntity<Void> decide(@PathVariable String turnId,
                                       @RequestBody ApprovalRequest body) {
        PendingTurn pending = pendingTurns.get(PendingTurnId.of(turnId))
            .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

        // 보류 턴은 자기가 어느 세션의 턴이었는지 알고 있다 — 다만 Optional 이다.
        // 사용자가 시킨 턴이 아니면(스케줄 태스크 등) 비어 있다.
        Optional<SessionId> sessionId = pending.getSessionId();

        for (PendingSkillRequest req : pending.getPendingSkills()) {
            SkillInvocationDecision decision = body.allows(req.getSkillName())
                ? SkillInvocationDecision.ALLOW
                : SkillInvocationDecision.DENY;

            if (sessionId.isPresent() && !body.forWholeAgent()) {
                // 기본 경로. 이 세션과 이 세션이 위임한 실행에만 적용된다.
                sessionApprovals.put(sessionId.get(), req.getSkillName(), decision);
            } else {
                // 주의: 이 결정은 이 세션이 아니라 pending.getAgentRuntimeId() 전체에 남고 만료되지 않는다.
                // 사용자가 "이 에이전트에서 항상"이라고 명시적으로 답했을 때만 이 가지로 온다.
                agentApprovals.put(pending.getAgentRuntimeId(), req.getSkillName(), decision);
            }
        }
        pendingTurns.remove(PendingTurnId.of(turnId));
        return ResponseEntity.accepted().build();
    }
}

승인을 되돌리는 엔드포인트도 같이 열어 두는 것이 좋다 — CLI 의 /revoke 와 동등한 역할이다. 두 스코프 모두 열어야 한다. 좁은 쪽만 열어 두면 사용자가 한 번 잘못 누른 에이전트 전역 승인을 되돌릴 방법이 UI 에 남지 않는다.

@DeleteMapping("/agent/sessions/{sessionId}/approvals")
public ResponseEntity<Void> revokeSession(@PathVariable String sessionId) {
    sessionApprovals.invalidate(SessionId.of(sessionId));      // /revoke
    return ResponseEntity.noContent().build();
}

@DeleteMapping("/agent/{agentRuntimeId}/approvals")
public ResponseEntity<Void> revokeAgent(@PathVariable String agentRuntimeId) {
    agentApprovals.invalidate(AgentRuntimeId.of(agentRuntimeId));   // /revoke --agent
    return ResponseEntity.noContent().build();
}

TTL 주의: PendingTurnReaper가 보류 턴을 일정 주기로 정리한다 (AgentSetupFactory.createPendingTurnReaper, 디폴트 60초 sweep). 클라이언트가 승인 UI를 너무 오래 띄워두면 턴이 expire되어 사용자 결정이 무시된다 — UX에서 카운트다운 또는 자동 거절을 알려야 한다. sweep 주기는 만료 여부가 아니라 만료된 항목을 얼마나 빨리 걷어내는지를 정한다 — 실제 만료 시각은 PendingTurn.getExpiresAt() 이다.


9. 기타 적응 시나리오

CLI/웹 가리지 않는 일반 패턴들.

9.1 자체 도구 등록

public class CompanyDirectoryTool extends AbstractTool {
    public static final String TOOL_NAME = "CompanyDirectory";
    private final DirectoryService directory;

    public CompanyDirectoryTool(DirectoryService directory) {
        super(TOOL_NAME,
              "Look up an employee by email or employee ID.",
              createInputSchema());
        this.directory = Objects.requireNonNull(directory);
    }
    // ... execute 구현
}

// 등록
agentRuntime.getToolRegistry().register(new CompanyDirectoryTool(svc));

AgentSetupFactory.registerCliTools()와 동일한 패턴이다. ToolRegistry는 runtime 하나에 하나이므로 등록도 runtime 생성 시 한 번이다 — 접속마다 등록하면 같은 도구가 중복 등록된다. 웹에서는 OrcaAgentRuntimeManager.builder().toolProviders(...)OrcaToolProvider로 넘기는 것이 정석이다 — 그러면 매니저가 runtime을 만들 때마다 정확히 한 번 등록해준다. (훅은 같은 자리에서 hookRegistrars(...)로 넘긴다.)

9.2 감사 로그 훅

// 이벤트 타입별 register* 메서드는 없다 — 타입 토큰 하나로 등록한다.
hookRegistry.register(HookEventType.PRE_TOOL, (PreToolHook) ctx -> {
    auditLog.info("invoker={} tool={} input={} attrs={}",
        ctx.getInvokerName(), ctx.getCurrentToolUse().getName(),
        ctx.getCurrentToolUse().getInput(), ctx.getExecutionAttributes());
    return HookResult.allow();
});

훅 종류와 차단 의미는 hook-development-guide.md를 따른다. PreToolHook만 차단(HookResult.block(reason)) 의미가 있다 — 다른 훅은 비차단이다.

감사 로그에 사용자 신원을 남기려면 훅 컨텍스트에서 꺼낼 수 없다는 점에 주의한다 — PreToolContextgetUserId() 같은 것은 없다. 제출 시점에 SubmitOptions.builder().executionAttribute("userId", ...) 로 실어 보내면 getExecutionAttributes() 로 그대로 도착한다. 훅 등록은 agent 스코프 HookRegistry 에 대해 일어나므로 이 역시 접속마다가 아니라 runtime 당 한 번이다 — 사용자 식별은 등록 시점이 아니라 제출 시점에 실려야 한다.


10. 체크리스트

새 호스트 애플리케이션에 aimon-core를 통합할 때 점검할 항목.

의존성

  • aimon-coreimplementation()으로 추가했는가?
  • 적어도 하나의 LLM 구현체 모듈을 추가했는가?
  • 필요한 파일시스템/스케줄링/지식 모듈을 골라 추가했는가?

컴포지션

  • LlmClient, OrcaAgentExecutor, SchedulingEngine애플리케이션 스코프로 만들었는가?
  • AgentRuntimeRegistry를 외부에서 만들어 SchedulingEngine에 주입했는가?
  • OrcaAgentRuntimeagent 스코프로 두고 OrcaAgentRuntimeManager.getOrCreateRuntime()으로만 얻는가? (세션마다 새로 만들지 않는가?)
  • AgentRuntimeIdfrom(agent) / from(agent, discriminator)로 유도하는가? (generate()는 존재하지 않는다)
  • 접속마다 새 LiveSession을 만들되, 같은 SessionId 로 열어 이전 세션을 이어받는가?
  • LiveSessionSessionRecordStore를 넘겨, 누적치·예산 오버라이드가 핸들보다 오래 살아남는가?
  • 같은 라이브 세션 안에서 MessageQueueManager단일 인스턴스인가?
  • (웹 한정) LlmClient/OrcaAgentExecutor를 사용자 요청마다 새로 만들지 않는가?
  • (웹 한정) MessageQueueManager를 앱 스코프 싱글톤으로 만들지 않는가?

라이프사이클

  • 라이브 세션 종료 시 liveSession.close()만 부르고, AgentRuntime은 닫지도 unregister 하지도 않는가?
  • 라이브 세션 종료에서 SchedulingEngine을 닫지 않는가? (CLI와의 차이)
  • AgentRuntime 해체를 앱 종료 또는 명시적 agent 제거 시 destroyRuntime()으로만 하는가?
  • 애플리케이션 종료 훅에서 SchedulingEngine, LlmClient, 공유 VirtualFileSystem을 닫는가?
  • agent-scoped 컴포넌트를 새로 추가했다면 OrcaAgentRuntime.close()하드코딩된 목록에 직접 넣었는가? (AgentScoped 마커는 문서일 뿐 fan-out 이 없다)
  • (웹 한정) 라이브 세션 보관소가 idle TTL eviction을 가지고 있고, eviction 시 핸들만 닫는가? (runtime까지 닫지 않는가?)

도구/훅/스킬

  • 자체 도구는 AbstractTool 규약(예외 안 던지고 ToolResult.error() 반환)을 지키는가?
  • PreToolHook 외에는 차단을 시도하지 않는가?
  • 헤드리스 환경이면 스킬 승인 정책이 자동 결정 또는 외부 승인 시스템에 위임되는가?
  • 승인 결정을 기록할 때 좁은 스코프(SessionApprovalStore)를 기본으로 두고, 에이전트 전역은 사용자가 명시적으로 답했을 때만 쓰는가?
  • 정책 체인이 좁은 것부터(세션 → 에이전트 → 규칙) 배치되어 있는가? 순서를 뒤집으면 세션 단위 거부가 도달하지 못한다
  • 승인 취소 경로를 두 스코프 모두 열었는가? (/revoke/revoke --agent 에 해당)
  • (웹 한정) 사람의 승인이 필요한 스킬은 동기 채널 대신 suspend/resume + HTTP 결정 엔드포인트를 쓰는가?

동시성 / HTTP

  • (웹 한정) 같은 SessionId 동시 요청을 offerAsync로 큐잉하거나 명시적으로 거절하는가?
  • (웹 한정) status() / currentTurnId() 를 제어 게이트로 쓰지 않고 SubmitOutcome 으로 판단하는가?
  • (웹 한정) SSE/WebSocket 스트림에서 클라이언트 끊김(IOException)을 감지해 cleanup 하는가?
  • (웹 한정) 사용자발 인터럽트를 interrupt(turnId, reason)턴을 지정해서 보내는가? (무인자 형태는 관리 목적용)
  • (웹 한정) interrupt() 호출이 즉시 종료를 보장하지 않는다는 점을 클라이언트에 노출했는가?

멀티 인스턴스 (선택)

  • in-memory 구현(InMemorySessionRecordStore, InMemoryMessageQueueRepository, InMemoryPendingTurnRegistry, InMemoryAgentApprovalStore, InMemorySessionApprovalStore)을 분산 백엔드로 교체했는가?
  • SchedulingEngine이 클러스터링 가능한 구현(aimon-scheduling-quartz)을 쓰는가?
  • (웹 한정) 세션 어피니티 라우팅을 쓰는가(SessionRouter), 아니면 LiveSession을 stateless로 매 요청마다 재구성하는가?

참고