지식위키

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이 더하는 자주 쓰는 메타:

이름필수설명
labelUI 표시용 이름 (예: "Read")
loadMode"essential"(초기 로드) vs "discoverable"(검색으로만 활성화)
execute메인 실행 콜백 → Promise<AgentToolResult>

실행 결과 AgentToolResult<T>:

이름필수설명
content모델에게 돌아가는 콘텐츠 블록 (TextContent | ImageContent)[]
detailsUI/로그용 구조화 데이터(영수증 성격)
isError비throw 실패 표식. agent-loop가 와이어 tool error로 변환

[!note]- 펼쳐보기: 전체 선택/메타 필드 목록 베이스 Tool의 선택 필드

  • strict — true면 실행 전 스키마로 엄격 검증
  • customFormat {syntax:"lark"|"regex"; definition} — OpenAI 커스텀툴 문법 제약(지원 프로바이더만)
  • customWireName — 와이어상 다른 이름(예: GPT-5 apply_patch). 디스패처가 name+customWireName 둘 다 매칭

AgentTool의 나머지 메타 필드

  • hidden — true면 --tools/agent.tools에 명시될 때만 노출
  • deferrableresolve 툴로 명시 해소가 필요한 보류 액션을 걸 수 있음
  • summary — 도구검색 색인용 한 줄 요약 (discoverable이면 사실상 필수)
  • nonAbortable — true면 abort 무시하고 끝까지 실행
  • concurrency "shared"|"exclusive" — 한 턴 다중 호출 시 동시성. exclusive는 단독 실행
  • lenientArgValidation — true면 인자검증 실패를 비치명적으로 처리(원본 args를 execute로 전달)
  • intent "omit"|"optional"|"require"|(args)=>..._i(INTENT_FIELD) 주입 정책. 기본 require
  • renderCall / renderResult — 호출/결과 표시용 커스텀 렌더

레지스트리/essential 해석 심볼 (packages/coding-agent/src/tools/index.ts):

심볼설명
BUILTIN_TOOLS공개 빌트인 레지스트리. BUILTIN_TOOLS[name](session)
HIDDEN_TOOLSyield/report_finding/resolve 등 숨김 도구
DEFAULT_ESSENTIAL_TOOL_NAMESoverride 비었을 때 기본 ["read","bash","edit"]
computeEssentialBuiltinNames(settings)override 있으면 그것(빌트인 존재 이름만), 없으면 기본값

[!note]- 펼쳐보기: 대표 도구의 정체성/노출 메타 비교 (직접 본 값)

도구nameloadMode비고
readreadessentiallabel=Read, path 1필드 :sel 인라인 셀렉터, strict·nonAbortable
bashbashessentiallabel=Bash, exclusive, async.enabled 시 스키마 확장, strict
editeditessentiallabel=Edit, exclusive, parameters가 5모드 union(replace/patch/hashline/vim/applyPatch)
ast_grepast_grepdiscoverablesummary 보유(검색 색인용)
search_tool_bm25search_tool_bm25essentiallabel=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) 결정.
  • executeAgentToolResult 반환 — 모델용 content + 영수증 details 분리.
  • 렌더러는 renderCall/renderResult로 분리, toolRenderers(renderers.ts)에 등록.
  • BUILTIN_TOOLS에 등록(조건부면 createIf).
  • 설정 게이팅 필요 시 createToolsisToolAllowed에 분기 추가.
  • 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 검색| 초기
  1. 요청 정규화: toolNames가 있으면 소문자/중복제거. goal.enabledgoal 자동 추가. text 도구가 있으면 AST 짝꿍 자동 동반(searchast_grep, editast_edit, bashrecipe — 각 설정 켜진 경우).
  2. 허용 게이팅 isToolAllowed(name): 도구별 설정값 확인. 예) evalallowEval, lspenableLsp && lsp.enabled, search_tool_bm25discoveryActive, task는 재귀깊이(task.maxRecursionDepth vs taskDepth). bash는 항상 true.
  3. 공장 호출: 통과 이름들을 factory(session)로 만들고 wrapToolWithMetaNotice로 감싼다. null은 버린다.
  4. resolve 보강: 결과에 resolve가 없으면 항상 추가(보류 액션 해소용 숨김 도구).
  5. 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)는 프로바이더별 정확도가 다르고, 정확 지명이 안 되면 능력을 한 단계 약화시키며 무한 재큐를 막는다.

스스로 답해보기:

  1. loadModeessential인 도구와 discoverable인 도구는 모델에게 노출되는 방식이 각각 어떻게 다른가?
  2. BUILTIN_TOOLS에서 s => new XTool(s) 패턴과 XTool.createIf 패턴은 언제 어느 것을 쓰며, 둘의 차이는 무엇인가?
  3. 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 처리)는 추측으로 바꾸지 않았다.