Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions ide/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "./ai/claude";
import { PROVIDERS_MAP, testProvider, getManagerId, setManagerId } from "./ai/registry";
import { LOCAL_PROVIDER } from "./ai/openaiCompat";
import { freshState as freshToolSupport, observe as observeToolSupport, type ToolSupportState } from "./ai/toolSupport";
import { CLAUDE_MODELS, CODEX_MODELS, OPENAI_MODELS, GROK_MODELS, GLM_MODELS, GEMINI_MODELS, ModelOpt } from "./ai/models";
import { Message, ToolCall, ToolDef, NeutralMsg, AgentProvider, getStoredKey, setStoredKey, getOAuth, setOAuth, freshOAuth, getModelOverride, setModelOverride, getEndpoint, setEndpoint } from "./ai/provider";
import { MonacoPane, paneRegistry } from "./editor/MonacoPane";
Expand Down Expand Up @@ -3763,6 +3764,10 @@ ${(r.output || "").slice(0, 2000)}`;
}
finalText = turnText || finalText;

// 이 모델이 도구를 쓸 줄 아는지 관찰한다. 못 쓰는 모델(작은 로컬 모델이 그렇다)은
// 말만 하고 끝나서 앱이 고장 난 것처럼 보인다 — 그럴 때 한 번 알려 준다.
this.observeToolUse(agentId, !!tools?.length, calls.length);

if (stopReason !== "tool_use" || calls.length === 0) break;

transcript.push({ role: "assistant", text: turnText || undefined, calls });
Expand Down Expand Up @@ -4977,6 +4982,17 @@ ${(r.output || "").slice(0, 2000)}`;
);
}

/** 프로바이더별 도구 사용 관찰. 세션 동안만 들고 있으면 된다 — 모델을 바꾸면
* 새로 보는 것이 맞다. */
private _toolSupport = new Map<string, ToolSupportState>();

private observeToolUse(agentId: string, sentTools: boolean, toolCalls: number) {
const prev = this._toolSupport.get(agentId) ?? freshToolSupport();
const { next, tell } = observeToolSupport(prev, { sentTools, toolCalls });
this._toolSupport.set(agentId, next);
if (tell) this.toast("info", t("oai.noToolCalls", { agent: this.agDef(agentId).name }));
}

componentDidMount() {
window.addEventListener("resize", this._clampChatOnResize);
applyTheme(getThemeId());
Expand Down
79 changes: 79 additions & 0 deletions ide/src/ai/toolSupport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { freshState, observe, STREAK_BEFORE_TELLING, type ToolSupportState } from "./toolSupport";

/** 턴을 여러 번 흘려 넣고 마지막 상태와 "말한 횟수" 를 돌려준다. */
function run(turns: { sentTools: boolean; toolCalls: number }[], start: ToolSupportState = freshState()) {
let s = start;
let tells = 0;
for (const t of turns) {
const r = observe(s, t);
s = r.next;
if (r.tell) tells++;
}
return { state: s, tells };
}

const empty = { sentTools: true, toolCalls: 0 };
const used = { sentTools: true, toolCalls: 2 };
const noTools = { sentTools: false, toolCalls: 0 };

describe("observe", () => {
it("도구를 한 번 쓰면 할 줄 아는 것으로 확정한다", () => {
const { state } = run([used]);
expect(state.everCalled).toBe(true);
expect(state.emptyStreak).toBe(0);
});

it("할 줄 아는 모델이 도구를 안 쓴 턴은 아무 일도 아니다", () => {
const { state, tells } = run([used, empty, empty, empty, empty, empty]);
expect(tells).toBe(0);
expect(state.everCalled).toBe(true);
});

// "이 함수 뭐 하는 거야" 같은 질문이 훨씬 많다 — 한 번 비었다고 경고하면 시끄럽다.
it("한두 번 비었다고 말하지 않는다", () => {
expect(run([empty]).tells).toBe(0);
expect(run([empty, empty]).tells).toBe(0);
});

it("연달아 세 번 비면 말한다", () => {
const { tells } = run(Array(STREAK_BEFORE_TELLING).fill(empty));
expect(tells).toBe(1);
});

it("한 프로바이더당 한 번만 말한다", () => {
const { tells, state } = run(Array(12).fill(empty));
expect(tells).toBe(1);
expect(state.told).toBe(true);
});

it("중간에 도구를 쓰면 연속이 끊긴다", () => {
const { tells } = run([empty, empty, used, empty, empty]);
expect(tells).toBe(0);
});

it("도구를 안 보낸 턴은 세지 않는다 — 판단 근거가 없다", () => {
const { state, tells } = run([noTools, noTools, noTools, noTools]);
expect(tells).toBe(0);
expect(state.emptyStreak).toBe(0);
});

it("도구 없는 턴이 섞여도 연속은 이어진다", () => {
const { tells } = run([empty, noTools, empty, noTools, empty]);
expect(tells).toBe(1);
});

it("입력 상태를 고쳐 쓰지 않는다", () => {
const before = freshState();
const snapshot = { ...before };
observe(before, empty);
expect(before).toEqual(snapshot);
});

it("이미 말한 뒤에도 도구를 쓰기 시작하면 확정된다", () => {
const told: ToolSupportState = { everCalled: false, emptyStreak: 5, told: true };
const { state } = run([used], told);
expect(state.everCalled).toBe(true);
expect(state.told).toBe(true);
});
});
59 changes: 59 additions & 0 deletions ide/src/ai/toolSupport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// 이 모델이 도구를 쓸 줄 아는가 — 물어볼 수 없으니 관찰해서 판단한다.
//
// 로컬 모델을 붙일 수 있게 되면서 생긴 문제다. 작은 모델은 tool use 를 아예 지원하지
// 않거나, 도구 정의를 받고도 그냥 산문으로 답한다. 그러면 에이전트는 "작업하기" 를
// 눌러도 파일 하나 안 건드리고 말만 하고 끝난다 — 사용자 눈에는 **앱이 고장 난 것**
// 으로 보인다. 실제로는 모델이 못 하는 것이고, 그건 말해 주면 되는 일이다.
//
// 그렇다고 도구를 안 쓴 턴마다 경고하면 안 된다. "이 함수 뭐 하는 거야" 처럼 도구가
// 필요 없는 질문이 훨씬 많다. 그래서 두 가지를 함께 본다:
// 1. 이 프로바이더가 **한 번이라도** 도구를 부른 적이 있는가 — 있으면 할 줄 아는 것이다.
// 2. 도구를 실어 보냈는데 아무것도 안 부른 턴이 연달아 몇 번인가.
// 둘 다 아니어야 말한다. 그리고 한 프로바이더당 한 번만 말한다.

export interface ToolSupportState {
/** 이 프로바이더가 도구를 부른 적이 있다 — 그러면 영원히 묻지 않는다. */
everCalled: boolean;
/** 도구를 줬는데 하나도 안 부른 턴이 연달아 몇 번인지. */
emptyStreak: number;
/** 이미 알린 적이 있다. */
told: boolean;
}

export function freshState(): ToolSupportState {
return { everCalled: false, emptyStreak: 0, told: false };
}

/** 몇 번 연속 비어야 말할지. 한 번은 흔하다 — 도구가 필요 없는 질문이 그렇다. */
export const STREAK_BEFORE_TELLING = 3;

export interface TurnOutcome {
/** 이 턴에 도구 정의를 실어 보냈는가. 안 보냈으면 판단 근거가 없다. */
sentTools: boolean;
/** 이 턴에 모델이 도구를 부른 횟수. */
toolCalls: number;
}

/**
* 턴 하나를 보고 상태를 갱신하고, 지금 알려야 하는지 답한다.
*
* 입력을 바꾸지 않는다 — 부르는 쪽이 돌려받은 상태를 저장한다.
*/
export function observe(prev: ToolSupportState, turn: TurnOutcome): { next: ToolSupportState; tell: boolean } {
// 도구를 안 보낸 턴은 아무 말도 해 주지 않는다(모델을 판단할 근거가 없다).
if (!turn.sentTools) return { next: prev, tell: false };

if (turn.toolCalls > 0) {
// 할 줄 안다는 것이 확인됐다. 다시는 의심하지 않는다.
return { next: { everCalled: true, emptyStreak: 0, told: prev.told }, tell: false };
}

if (prev.everCalled) {
// 할 줄 아는 모델이 이번엔 안 쓴 것뿐이다 — 그건 정상이다.
return { next: prev, tell: false };
}

const emptyStreak = prev.emptyStreak + 1;
const tell = !prev.told && emptyStreak >= STREAK_BEFORE_TELLING;
return { next: { everCalled: false, emptyStreak, told: prev.told || tell }, tell };
}
7 changes: 7 additions & 0 deletions ide/src/i18n/dict/oai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export const dict: Record<string, { ko: string; en: string; de: string; ja: stri
ja: "応答に失敗しました: {detail}",
},
// 도구 인자 JSON 이 깨진 경우 — 조용히 빈 입력으로 넘기면 빈 경로 제안이 만들어진다
// 도구를 못 쓰는 모델이면 에이전트가 말만 하고 끝난다 — 앱이 고장 난 것처럼 보인다.
"oai.noToolCalls": {
ko: "{agent} 이(가) 도구를 한 번도 쓰지 않았습니다. 이 모델은 도구 호출(tool use)을 지원하지 않을 수 있습니다 — 그러면 파일을 고치지 못하고 말로만 답합니다.",
en: "{agent} has not used a tool once. This model may not support tool calling — if so it can only talk, not change files.",
de: "{agent} hat kein einziges Werkzeug benutzt. Dieses Modell unterstützt möglicherweise keine Tool-Aufrufe — dann kann es nur reden, keine Dateien ändern.",
ja: "{agent} が一度もツールを使いませんでした。このモデルはツール呼び出しに対応していない可能性があります — その場合、ファイルは変更できず会話だけになります。",
},
// 로컬 모델은 키가 아니라 주소가 설정이다 — 없을 때 "API 키" 를 말하면 엉뚱한 데를 찾게 한다.
"oai.localNoEndpoint": {
ko: "로컬 서버 주소가 설정돼 있지 않습니다. 설정에서 주소를 적어 주세요(예: http://localhost:11434).",
Expand Down
2 changes: 1 addition & 1 deletion ide/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default defineConfig({
"src/engine/**/*.test.ts",
"src/gameEngine/**/*.test.ts",
"src/review/**/*.test.ts",
"src/ai/retry.test.ts", "src/ai/endpoint.test.ts",
"src/ai/retry.test.ts", "src/ai/endpoint.test.ts", "src/ai/toolSupport.test.ts",
"src/keymap.test.ts",
"src/overlays.test.ts",
"src/ext/hooks.test.ts", "src/ext/prompt.test.ts", "src/ext/shimLang.test.ts", "src/ext/config.test.ts", "src/ext/statusBar.test.ts", "src/ext/fsWatch.test.ts", "src/ext/views.test.ts", "src/ext/workspaceEdit.test.ts", "src/ext/decorations.test.ts", "src/ext/fileOps.test.ts", "src/mcp/mcpExtras.test.ts",
Expand Down
Loading