gajae-code · 도구 시스템: 정의 형식과 모델 노출
한 줄 요약
AI가 쓸 수 있는 모든 능력(파일 읽기·명령 실행·코드 수정)은 도구(tool) 한 단위로 정의되고, 그중 어떤 것을 모델에게 보여줄지 까다롭게 골라 노출한다. 왜 배우나: “에이전트가 할 수 있는 것”과 “지금 보여주는 것”을 구분하는 규칙이 여기서 정해지기 때문이다.
그림
flowchart TD A["세션 시작 · createTools(요청 도구 목록)"] --> B["자동 동반 추가: AST/recipe/goal 짝꿍"] B --> C{"이 도구 켜도 되나?<br/>isToolAllowed"} C -->|허용| D["공장 호출 → AgentTool 인스턴스"] C -->|거부| X["목록에서 제외"] D --> E["메타 안내문으로 감싸기"] E --> F{"노출 모드 loadMode"} F -->|essential 필수| G["초기 도구 배열 → 모델에게 보여줌"] F -->|discoverable 검색형| H["숨김 색인에 보관"] H -.->|"모델이 search_tool_bm25로 검색"| I["activateDiscoveredTools 활성화"] I --> G G --> J["모델이 tool_call 호출"] J --> K["execute 실행 → 결과(content + details)"] K --> L["renderResult 로 화면 표시"] M["도구 강제 큐 ToolChoiceQueue"] -->|"다음 턴 이 도구 써라"| G M -->|"강제 실패 → 약화(degrade)"| M
쉽게 풀기
도구 시스템을 “공구함을 갖춘 작업자”에 비유하면 쉽다.
flowchart LR subgraph 공구함["공구함 = BUILTIN_TOOLS 레지스트리"] F1["이름 → 공장(factory)"] end F1 -->|"BUILTIN_TOOLS[name](session)"| T["도구 1개 = AgentTool<br/>name·label·description·parameters"] T --> 서랍{loadMode} 서랍 -->|essential| 눈앞["작업자(모델) 눈앞"] 서랍 -->|discoverable| 서랍속["서랍 보관 → 검색 시 꺼냄"]
1. 도구 1개 = 규격에 맞춘 공구. 도구 하나는 AgentTool 인터페이스를 만족하는 객체로, 이름표(name)·UI 라벨(label)·설명서(description)·입력 양식(parameters)을 갖춘다.
2. 공구함 = 레지스트리. 모든 빌트인 도구는 BUILTIN_TOOLS(“이름 → 만드는 법”) 사전에 등록된다. BUILTIN_TOOLS["read"](session)처럼 이름+세션을 넣으면 그 자리에서 찍어낸다. 미리 만들지 않고 필요할 때 생성한다.
3. 한꺼번에 안 보여준다 (점진 공개). 공구가 수십 개면 작업자가 헷갈린다. 처음엔 read·bash·edit 같은 필수(essential) 만 꺼내 두고, 나머지는 서랍에 넣는다. 모델이 search_tool_bm25로 찾으면 그때 꺼내 준다. 이 점진 공개(progressive disclosure) 가 선택 품질 저하와 토큰 낭비를 막는다.
4. “이번엔 꼭 이 공구 써” 강제 (ToolChoice). 모델에게 “다음 턴엔 반드시 이 도구”라고 못 박을 수 있다. 단 프로바이더마다 강제 정도가 달라, 정확히 지명되는 곳도 있고 “아무 도구나 하나는 써”까지만 되는 곳도 있다. 지명이 안 되면 강제를 한 단계 약화(degradation)시키며, 이때도 무한 반복에 빠지지 않게 안전장치를 둔다.
핵심 정리
도구 정의의 3겹 구조
- 베이스
Tool— 공통 토대. 이름·설명·입력양식 (packages/ai/src/types.ts:667)- 확장
AgentTool— UI 라벨·노출 모드·동시성 등 에이전트용 메타 추가 (packages/agent/src/types.ts:411)- 결과
AgentToolResult— 모델용 내용과 로그용 데이터를 분리 (packages/agent/src/types.ts:370)
베이스 Tool<TParameters>의 핵심 필드:
| 이름 | 필수 | 설명 |
|---|---|---|
name | 호출/디스패치용 이름 (예: "read") | |
description | 모델에게 보이는 설명(보통 프롬프트 템플릿 렌더 결과) | |
parameters | 인자 스키마(zod). 모델엔 JSON Schema로 노출 |
확장 AgentTool이 더하는 자주 쓰는 메타:
| 이름 | 필수 | 설명 |
|---|---|---|
label | UI 표시용 이름 (예: "Read") | |
loadMode | "essential"(초기 로드) vs "discoverable"(검색으로만 활성화) | |
execute | 메인 실행 콜백 → Promise<AgentToolResult> |
실행 결과 AgentToolResult<T>:
| 이름 | 필수 | 설명 |
|---|---|---|
content | 모델에게 돌아가는 콘텐츠 블록 (TextContent | ImageContent)[] | |
details | UI/로그용 구조화 데이터(영수증 성격) | |
isError | 비throw 실패 표식. agent-loop가 와이어 tool error로 변환 |
펼쳐보기: 전체 선택/메타 필드 목록
베이스 Tool의 선택 필드
strict— true면 실행 전 스키마로 엄격 검증customFormat{syntax:"lark"|"regex"; definition}— OpenAI 커스텀툴 문법 제약(지원 프로바이더만)customWireName— 와이어상 다른 이름(예: GPT-5apply_patch). 디스패처가name+customWireName둘 다 매칭AgentTool의 나머지 메타 필드
hidden— true면--tools/agent.tools에 명시될 때만 노출deferrable—resolve툴로 명시 해소가 필요한 보류 액션을 걸 수 있음summary— 도구검색 색인용 한 줄 요약 (discoverable이면 사실상 필수)nonAbortable— true면 abort 무시하고 끝까지 실행concurrency"shared"|"exclusive"— 한 턴 다중 호출 시 동시성. exclusive는 단독 실행lenientArgValidation— true면 인자검증 실패를 비치명적으로 처리(원본 args를 execute로 전달)intent"omit"|"optional"|"require"|(args)=>...—_i(INTENT_FIELD) 주입 정책. 기본requirerenderCall/renderResult— 호출/결과 표시용 커스텀 렌더
레지스트리/essential 해석 심볼 (packages/coding-agent/src/tools/index.ts):
| 심볼 | 설명 |
|---|---|
BUILTIN_TOOLS | 공개 빌트인 레지스트리. BUILTIN_TOOLS[name](session) |
HIDDEN_TOOLS | yield/report_finding/resolve 등 숨김 도구 |
DEFAULT_ESSENTIAL_TOOL_NAMES | override 비었을 때 기본 ["read","bash","edit"] |
computeEssentialBuiltinNames(settings) | override 있으면 그것(빌트인 존재 이름만), 없으면 기본값 |
펼쳐보기: 대표 도구의 정체성/노출 메타 비교 (직접 본 값)
도구 name loadMode 비고 read readessential label=Read, path 1필드 :sel인라인 셀렉터, strict·nonAbortablebash bashessential label=Bash, exclusive, async.enabled 시 스키마 확장, strict edit editessential label=Edit, exclusive, parameters가 5모드 union(replace/patch/hashline/vim/applyPatch) ast_grep ast_grepdiscoverable summary 보유(검색 색인용) search_tool_bm25 search_tool_bm25essential label=SearchTools(name과 다름, 와이어 back-compat), strict
실제 예시
펼쳐보기: 도구 규격 본체 —
AgentTool인터페이스 전문// packages/agent/src/types.ts export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any, TTheme = unknown> extends Tool<TParameters> { label: string; hidden?: boolean; deferrable?: boolean; /** "essential" loads initially; "discoverable" can be activated by tool search. */ loadMode?: "essential" | "discoverable"; summary?: string; nonAbortable?: boolean; concurrency?: "shared" | "exclusive"; lenientArgValidation?: boolean; intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined); execute: AgentToolExecFn<TParameters, TDetails, TTheme>; renderCall?: (args: Static<TParameters>, options: RenderResultOptions, theme: TTheme) => unknown; renderResult?: (result: AgentToolResult<TDetails, TParameters>, options: RenderResultOptions, theme: TTheme) => unknown; }
공구함 — 이름→공장 레지스트리. 등록 패턴은 두 가지다(항상 생성 vs 조건부 생성):
// packages/coding-agent/src/tools/index.ts
export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = ["read", "bash", "edit"] as const;
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
read: s => new ReadTool(s),
bash: s => new BashTool(s),
edit: s => new EditTool(s),
ast_grep: s => new AstGrepTool(s),
github: GithubTool.createIf, // createIf → 조건부 생성(불가시 null)
search_tool_bm25: SearchToolBm25Tool.createIf,
skill: SkillTool.createIf,
goal: s => new GoalTool(s),
// ...
};항상 만들어지는 도구는
s => new XTool(s), 설정/환경 따라 빠질 수 있는 도구는 정적XTool.createIf(null 반환 가능).null이면 그 턴 목록에서 빠진다.
입력 양식(args 스키마)은 zod로 정의하고, 각 필드의 .describe() 텍스트가 그대로 모델에게 인자 설명으로 나간다:
// read.ts — strict 1필드, 인라인 셀렉터
const readSchema = z.object({
path: z.string().describe('path or url; append :<sel> for line ranges (e.g. "src/foo.ts:50-100")'),
}).strict();
// ast-grep.ts — discoverable + summary
const astGrepSchema = z.object({
pat: z.string().describe("ast pattern"),
paths: z.array(z.string()).min(1).describe("files, directories, globs, or internal URLs"),
skip: z.number().default(0).optional(),
});
// readonly loadMode = "discoverable"; readonly summary = "Search code with AST patterns";펼쳐보기: bash 스키마(async 확장 포함) 전문
// packages/coding-agent/src/tools/bash.ts const bashSchemaBase = z.object({ command: z.string().describe("command to execute"), env: z.record(z.string().regex(BASH_ENV_NAME_PATTERN), z.string()).optional().describe("extra env vars"), timeout: z.number().default(300).describe("timeout in seconds, NOT milliseconds (30 = 30s)").optional(), cwd: z.string().describe("working directory").optional(), pty: z.boolean().describe("run in pty mode").optional(), }); const bashSchemaWithAsync = bashSchemaBase.extend({ async: z.boolean().describe("run in background").optional(), // async.enabled일 때만 .extend });
펼쳐보기: 직접 도구 만들기 템플릿 (AgentTool implements + 등록)
// my-tool.ts import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core"; import * as z from "zod/v4"; import type { ToolSession } from "."; const mySchema = z.object({ target: z.string().describe("what to operate on"), dry_run: z.boolean().optional().describe("preview only, do not apply"), }); export interface MyToolDetails { affected: number; preview?: string } export class MyTool implements AgentTool<typeof mySchema, MyToolDetails> { readonly name = "my_tool"; readonly label = "MyTool"; readonly loadMode = "discoverable"; // essential이 아니면 검색으로만 노출 readonly summary = "Do the thing structurally"; // 검색 색인용 한 줄 readonly parameters = mySchema; readonly strict = true; readonly description = "Operate on <target>. Use dry_run to preview."; constructor(private readonly session: ToolSession) {} // 조건부 생성: static createIf(s){ return s.settings.get("myTool.enabled") ? new MyTool(s) : null; } async execute(_id: string, params: z.infer<typeof mySchema>): Promise<AgentToolResult<MyToolDetails>> { const affected = 1; // ... 실제 작업 return { content: [{ type: "text", text: `done: ${params.target}` }], details: { affected }, // 영수증/렌더용 구조화 데이터 }; } } // packages/coding-agent/src/tools/index.ts (레지스트리 등록) // my_tool: MyTool.createIf, // 또는 s => new MyTool(s)
새 도구 만들기 체크리스트:
-
name(와이어),label(UI),description,parameters(zod, 각 필드.describe()). -
loadMode: essential vs discoverable. discoverable이면summary필수. -
strict/nonAbortable/concurrency(부작용 큰 도구는exclusive) 결정. -
execute는AgentToolResult반환 — 모델용content+ 영수증details분리. - 렌더러는
renderCall/renderResult로 분리,toolRenderers(renderers.ts)에 등록. -
BUILTIN_TOOLS에 등록(조건부면createIf). - 설정 게이팅 필요 시
createTools의isToolAllowed에 분기 추가. - essential 화이트리스트 변경은
tools.essentialOverride설정 사용.
도구가 모델에게 노출되기까지 (선택 파이프라인)
세션 생성 시 createTools(session, toolNames?)(index.ts:401)가 호출되며 노출 도구가 결정된다. 단계는 아래 흐름과 같다.
flowchart TD S1["1 요청 정규화<br/>소문자·중복제거, goal/AST 짝꿍 자동추가"] --> S2{"2 isToolAllowed(name)<br/>설정값 게이팅"} S2 -->|통과| S3["3 factory(session) 생성<br/>+ wrapToolWithMetaNotice"] S2 -->|"거부/null"| 버림["제외"] S3 --> S4["4 resolve 없으면 자동 추가"] S4 --> S5{"5 loadMode"} S5 -->|essential| 초기["초기 목록 → 모델 노출"] S5 -->|discoverable| 숨김["숨김 색인 보관"] 숨김 -.->|search_tool_bm25 검색| 초기
- 요청 정규화:
toolNames가 있으면 소문자/중복제거.goal.enabled면goal자동 추가. text 도구가 있으면 AST 짝꿍 자동 동반(search→ast_grep,edit→ast_edit,bash→recipe— 각 설정 켜진 경우). - 허용 게이팅
isToolAllowed(name): 도구별 설정값 확인. 예)eval은allowEval,lsp는enableLsp && lsp.enabled,search_tool_bm25는discoveryActive,task는 재귀깊이(task.maxRecursionDepthvstaskDepth).bash는 항상 true. - 공장 호출: 통과 이름들을
factory(session)로 만들고wrapToolWithMetaNotice로 감싼다. null은 버린다. - resolve 보강: 결과에
resolve가 없으면 항상 추가(보류 액션 해소용 숨김 도구). - essential vs discoverable:
essential만 초기 목록에 들어가고,discoverable(예: ast_grep)은 숨겨뒀다가search_tool_bm25로만 활성화된다.
펼쳐보기: 점진 공개의 실제 동작 (search_tool_bm25)
tools.discoveryMode !== "off"(또는 레거시mcp.discoveryMode)일 때만 활성. 모델이{query, limit?}로 호출 → 세션 discoverable 색인(getDiscoverableToolSearchIndex)에서 BM25 랭킹 → 이미 선택된 것 제외 후 limit개 →activateDiscoveredTools(names)로 활성셋에 병합. 결과details(SearchToolBm25Details)에activated_tools,active_selected_tools,total_tools, 점수 매치 리스트가 담겨 영수증 역할을 한다.
도구 강제(ToolChoice)는 프로바이더별로 정확도가 달라, 정확 지명이 안 되면 능력 약화로 떨어진다.
flowchart TD Q["ToolChoiceQueue 지시<br/>'다음 턴 이 도구 써라'"] --> B["buildNamedToolChoiceResult(name, model)"] B -->|"Anthropic/Bedrock"| N1["{"type:tool, name"} 정확 지명"] B -->|"OpenAI/Ollama"| N2["{"type:function, name"} 정확 지명"] B -->|Google 계열| D1["'required' 지명불가 → degradation"] N1 --> R{"resolveToolChoice<br/>exactNamed?"} N2 --> R D1 --> R R -->|정확| OK["resolve/todo_write/yield 등 안전 게이트 통과"] R -->|실패| DG["degradeInFlight() → 지시 드롭<br/>무한 재큐 방지"]
펼쳐보기: ToolChoice 강제와 능력 약화 (utils/tool-choice.ts)
buildNamedToolChoiceResult(toolName, model)이 프로바이더별 강제 형태를 만든다.
- Anthropic/Bedrock →
{type:"tool", name}(정확 지명)- OpenAI 계열/Ollama →
{type:"function", name}(정확 지명)- Google 계열 →
"required"(지명 불가 → degradation: 아무 도구나 강제)
resolveToolChoice로 해소 후exactNamed(namedShape && resolvedLevel===“named” && targetToolName 일치)를 계산. resolve/todo_write/yield처럼 정확한 도구 정체성이 필요한 큐 지시는exactNamed로 게이트해야 한다(레거시buildNamedToolChoice는 lossy"required"로 떨어질 수 있어 비권장). 런타임에서 강제가 실패하면ToolChoiceQueue.degradeInFlight()가 in-flight 지시를 onRejected 우회로 드롭해 무한 재큐를 막는다.
요약 & 셀프체크
3줄 요약:
- 도구는
AgentTool규격을 만족하는 객체이고,BUILTIN_TOOLS사전에 “이름→공장”으로 등록돼 필요할 때 찍어낸다. - 모델엔
read·bash·edit같은 필수(essential)만 먼저 보여주고, 나머지(discoverable)는search_tool_bm25검색으로만 꺼내는 점진 공개를 쓴다. - “다음 턴에 이 도구 써라” 강제(ToolChoice)는 프로바이더별 정확도가 다르고, 정확 지명이 안 되면 능력을 한 단계 약화시키며 무한 재큐를 막는다.
스스로 답해보기:
loadMode가essential인 도구와discoverable인 도구는 모델에게 노출되는 방식이 각각 어떻게 다른가?BUILTIN_TOOLS에서s => new XTool(s)패턴과XTool.createIf패턴은 언제 어느 것을 쓰며, 둘의 차이는 무엇인가?- Google 계열 프로바이더에서 “정확히 이 도구를 써라” 강제가 왜 그대로 통하지 않으며, 시스템은 그때 어떻게 대처하는가?
연결
GJ_개요 · _분석축_루브릭 · GJ_10_agent-loop · GJ_50_mcp-integration · GJ_70_guardrails-sandbox-permission-gating
근거 파일
/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/tools/index.ts(BUILTIN_TOOLS, HIDDEN_TOOLS, ToolSession, ToolFactory, computeEssentialBuiltinNames, DEFAULT_ESSENTIAL_TOOL_NAMES, createTools/isToolAllowed)/home/seunghyeong/harness-work/gajae-code/packages/agent/src/types.ts(AgentTool, AgentToolResult, AgentToolExecFn, RenderResultOptions)/home/seunghyeong/harness-work/gajae-code/packages/ai/src/types.ts(베이스 Tool, customWireName/customFormat)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/tools/search-tool-bm25.ts(progressive disclosure, SearchToolBm25Details, createIf)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/tools/bash.ts(bashSchemaBase/WithAsync, exclusive, 정체성 메타)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/tools/read.ts(readSchema, ReadToolDetails, nonAbortable)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/tools/ast-grep.ts(discoverable loadMode + summary, astGrepSchema)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/edit/index.ts(EditTool, 5모드 parameters union)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/tools/renderers.ts(toolRenderers 렌더러 분리)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/utils/tool-choice.ts(buildNamedToolChoiceResult, exactNamed, degradation)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/session/tool-choice-queue.ts(ToolChoiceDirective, degradeInFlight)/home/seunghyeong/harness-work/gajae-code/packages/coding-agent/src/capability/tool.ts(CustomTool capability — 사용자정의 도구 등록)
Codex 교차검증 메모
원문에는 별도 Codex 교차검증 섹션이 없었다. 재편집 과정에서 스키마·필드값·파일경로·코드예시·근거는 변경 없이 보존했고, 길이만 압축(중복 문단·반복 설명 제거)하고 상세는 접이식 콜아웃으로, 복잡 구간엔 인라인 mermaid를 추가했다. 사실관계(essential 기본값
["read","bash","edit"], 프로바이더별 ToolChoice 형태, degradation 처리)는 추측으로 바꾸지 않았다.