-
Notifications
You must be signed in to change notification settings - Fork 93
feat: [AI-7392] humanize tool-call titles at the source #980
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,89 @@ | ||||||||||||||||||
| /** | ||||||||||||||||||
| * Produces a readable, dbt-aware title for a tool call — e.g. "Reading customers | ||||||||||||||||||
| * model" instead of a bare file path — so any client (chat webview, TUI, ...) can | ||||||||||||||||||
| * render a descriptive label straight from the tool part's `state.title`. | ||||||||||||||||||
| * | ||||||||||||||||||
| * This is the source of truth for tool-call labels: it runs inside the tool | ||||||||||||||||||
| * execute() wrapper (see `tool/tool.ts`) and rewrites the title every tool | ||||||||||||||||||
| * returns. Only file-acting tools (whose native title is a bare path) are | ||||||||||||||||||
| * rewritten; every other tool keeps the rich title it already emits. | ||||||||||||||||||
| * | ||||||||||||||||||
| * dbt naming ("model"/"seed"/...) is applied only when the path sits under the | ||||||||||||||||||
| * matching directory, so it degrades to the plain filename off-dbt. | ||||||||||||||||||
| */ | ||||||||||||||||||
|
|
||||||||||||||||||
| /** File-acting tools whose native title is a bare path → gerund verb. */ | ||||||||||||||||||
| const FILE_TOOL_VERBS: Record<string, string> = { | ||||||||||||||||||
| read: "Reading", | ||||||||||||||||||
| write: "Writing", | ||||||||||||||||||
| edit: "Editing", | ||||||||||||||||||
| multiedit: "Editing", | ||||||||||||||||||
| glob: "Searching", | ||||||||||||||||||
| grep: "Searching", | ||||||||||||||||||
| list: "Listing", | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** dbt directory → singular noun used in the label. */ | ||||||||||||||||||
| const DBT_DIR_KIND: Record<string, string> = { | ||||||||||||||||||
| models: "model", | ||||||||||||||||||
| seeds: "seed", | ||||||||||||||||||
| macros: "macro", | ||||||||||||||||||
| snapshots: "snapshot", | ||||||||||||||||||
| tests: "test", | ||||||||||||||||||
| analyses: "analysis", | ||||||||||||||||||
| analysis: "analysis", | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| function asString(value: unknown): string | undefined { | ||||||||||||||||||
| return typeof value === "string" && value.trim() ? value : undefined | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * Turn a file path into a friendly target: | ||||||||||||||||||
| * - under a known dbt dir → "<name> <kind>" with the sql/yaml/csv extension stripped | ||||||||||||||||||
| * - otherwise → the basename, extension kept (e.g. "dbt_project.yml", "index.ts") | ||||||||||||||||||
| */ | ||||||||||||||||||
| function friendlyTarget(rawPath: string): string { | ||||||||||||||||||
| const segments = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").split("/").filter(Boolean) | ||||||||||||||||||
| const base = segments[segments.length - 1] ?? rawPath | ||||||||||||||||||
| for (const segment of segments.slice(0, -1)) { | ||||||||||||||||||
| const kind = DBT_DIR_KIND[segment.toLowerCase()] | ||||||||||||||||||
|
Comment on lines
+49
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟠 MEDIUM] Iterating left-to-right (from the root down) can incorrectly match an outer directory that coincidentally shares a name with a dbt folder, rather than the intended specific directory (e.g., an absolute path like Consider iterating right-to-left (from the file upwards) to match the most specific parent directory. Suggested change:
Suggested change
|
||||||||||||||||||
| if (kind) { | ||||||||||||||||||
| const name = base.replace(/\.(sql|ya?ml|csv)$/i, "") | ||||||||||||||||||
| return `${name} ${kind}` | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+51
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [🟠 MEDIUM] The regex misses Suggested change:
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| return base | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+46
to
+57
|
||||||||||||||||||
|
|
||||||||||||||||||
| /** Extract the display target for a given file tool from its input args. */ | ||||||||||||||||||
| function fileTarget(tool: string, input: Record<string, unknown>): string | undefined { | ||||||||||||||||||
| if (tool === "glob" || tool === "grep") { | ||||||||||||||||||
| return asString(input["pattern"]) | ||||||||||||||||||
| } | ||||||||||||||||||
| if (tool === "list") { | ||||||||||||||||||
| const path = asString(input["path"]) | ||||||||||||||||||
| return path ? friendlyTarget(path) : undefined | ||||||||||||||||||
| } | ||||||||||||||||||
| // read / write / edit / multiedit | ||||||||||||||||||
| const filePath = asString(input["filePath"]) ?? asString(input["path"]) | ||||||||||||||||||
| return filePath ? friendlyTarget(filePath) : undefined | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| /** | ||||||||||||||||||
| * @param tool the tool id (e.g. "read", "sql_analyze") | ||||||||||||||||||
| * @param input the tool's input args | ||||||||||||||||||
| * @param rawTitle the title the tool itself returned (a bare path for file tools, | ||||||||||||||||||
| * already human-readable for everything else) | ||||||||||||||||||
| * @returns a humanized label for file tools, otherwise the tool's own title. | ||||||||||||||||||
| */ | ||||||||||||||||||
| export function describeToolCall(tool: string, input: unknown, rawTitle?: string): string | undefined { | ||||||||||||||||||
| const fallback = asString(rawTitle) | ||||||||||||||||||
| const verb = FILE_TOOL_VERBS[tool] | ||||||||||||||||||
| if (verb && input && typeof input === "object") { | ||||||||||||||||||
| const target = fileTarget(tool, input as Record<string, unknown>) | ||||||||||||||||||
| if (target) return `${verb} ${target}` | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: When Prompt for AI agents |
||||||||||||||||||
| } | ||||||||||||||||||
| // Non-file / rich-title tools: keep the title the tool already emitted. | ||||||||||||||||||
| return fallback | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+80
to
+89
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { describe, test, expect } from "bun:test" | ||
| import { describeToolCall } from "../../src/altimate/tool-label" | ||
|
|
||
| describe("describeToolCall", () => { | ||
| test("humanizes reads of a dbt model into 'Reading <name> model'", () => { | ||
| expect(describeToolCall("read", { filePath: "models/customers.sql" }, "models/customers.sql")).toBe( | ||
| "Reading customers model", | ||
| ) | ||
| expect( | ||
| describeToolCall("read", { filePath: "models/staging/stg_customers.sql" }, "models/staging/stg_customers.sql"), | ||
| ).toBe("Reading stg_customers model") | ||
| }) | ||
|
|
||
| test("maps other dbt directories to their noun", () => { | ||
| expect(describeToolCall("edit", { filePath: "macros/cents_to_dollars.sql" }, "macros/cents_to_dollars.sql")).toBe( | ||
| "Editing cents_to_dollars macro", | ||
| ) | ||
| expect(describeToolCall("write", { filePath: "analyses/rollup.sql" }, "analyses/rollup.sql")).toBe( | ||
| "Writing rollup analysis", | ||
| ) | ||
| expect(describeToolCall("read", { filePath: "seeds/raw_customers.csv" }, "seeds/raw_customers.csv")).toBe( | ||
| "Reading raw_customers seed", | ||
| ) | ||
| }) | ||
|
|
||
| test("falls back to the filename for non-dbt paths (never a false 'model')", () => { | ||
| expect(describeToolCall("read", { filePath: "dbt_project.yml" }, "dbt_project.yml")).toBe("Reading dbt_project.yml") | ||
| expect(describeToolCall("read", { filePath: "src/index.ts" }, "src/index.ts")).toBe("Reading index.ts") | ||
| }) | ||
|
|
||
| test("labels glob / grep / list by their target", () => { | ||
| expect(describeToolCall("glob", { pattern: "**/*.sql" }, "12 matches")).toBe("Searching **/*.sql") | ||
| expect(describeToolCall("grep", { pattern: "customer_id" }, "3 matches")).toBe("Searching customer_id") | ||
| expect(describeToolCall("list", { path: "models" }, "models/")).toBe("Listing models") | ||
| }) | ||
|
|
||
| test("keeps the tool's own title for non-file / rich-title tools", () => { | ||
| expect( | ||
| describeToolCall("sql_analyze", { filePath: "models/customers.sql" }, "Analyze: 2 issues [high]"), | ||
| ).toBe("Analyze: 2 issues [high]") | ||
| expect(describeToolCall("bash", { command: "dbt build" }, "Run full dbt build")).toBe("Run full dbt build") | ||
| // apply_patch carries a diff, not a path, so it keeps its own per-file title. | ||
| expect(describeToolCall("apply_patch", { patch: "*** Update File: models/x.sql" }, "# Patched x.sql")).toBe( | ||
| "# Patched x.sql", | ||
| ) | ||
| }) | ||
|
|
||
| test("falls back to the raw title when a file tool has no usable path", () => { | ||
| expect(describeToolCall("read", {}, "some title")).toBe("some title") | ||
| expect(describeToolCall("read", undefined, "some title")).toBe("some title") | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Non-dbt files under common directories like
src/modelscan be shown as dbt objects becausefriendlyTarget()applies the dbt noun to any path segment namedmodels,tests, ormacros, regardless of the target file type. That makes labels such asReading User.tsx modelpossible outside dbt projects; consider only applying the dbt noun when the target looks like a dbt resource (for example known dbt file extensions) and otherwise falling back to the basename.Prompt for AI agents