gajae-code · 도구 시스템: 정의 형식과 모델 노출
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)시키며, 이때도 무한 반복에 빠지지 않게 안전장치를 둔다.
핵심 정리
[!note] 도구 정의의 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로 변환 |
[!note]- 펼쳐보기: 전체 선택/메타 필드 목록 베이스 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 있으면 그것(빌트인 존재 이름만), 없으면 기본값 |
[!note]- 펼쳐보기: 대표 도구의 정체성/노출 메타 비교 (직접 본 값)
도구 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
실제 예시
[!note]- 펼쳐보기: 도구 규격 본체 —
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";
[!note]- 펼쳐보기: 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 });
[!note]- 펼쳐보기: 직접 도구 만들기 템플릿 (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로만 활성화된다.
[!note]- 펼쳐보기: 점진 공개의 실제 동작 (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/>무한 재큐 방지"]
[!note]- 펼쳐보기: 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 — 사용자정의 도구 등록)
[!tip] Codex 교차검증 메모 원문에는 별도 Codex 교차검증 섹션이 없었다. 재편집 과정에서 스키마·필드값·파일경로·코드예시·근거는 변경 없이 보존했고, 길이만 압축(중복 문단·반복 설명 제거)하고 상세는 접이식 콜아웃으로, 복잡 구간엔 인라인 mermaid를 추가했다. 사실관계(essential 기본값
["read","bash","edit"], 프로바이더별 ToolChoice 형태, degradation 처리)는 추측으로 바꾸지 않았다.