Skip to content
Merged
98 changes: 98 additions & 0 deletions packages/opencode/src/altimate/tool-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* 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
}

/** dbt file extensions that mark a target as a single dbt node (model/seed/...). */
const DBT_FILE_EXT = /\.(sql|ya?ml|csv|py|md)$/i

/**
* Turn a file path into a friendly target:
* - a dbt *file* under a known dbt dir → "<name> <kind>" with the extension stripped
* - otherwise → the basename (e.g. "dbt_project.yml", "index.ts", or a directory name)
*
* The dbt-kind rewrite is gated on a recognized dbt file extension so directory
* targets (e.g. `list models/staging`) aren't mislabeled as a single model, and
* the nearest dbt ancestor is matched by scanning right-to-left so a coincidental
* outer directory name (e.g. a repo called `models/`) doesn't win over the real one.
*/
function friendlyTarget(rawPath: string): string {
const segments = rawPath.replace(/\\/g, "/").replace(/^\.\//, "").split("/").filter(Boolean)
const base = segments[segments.length - 1] ?? rawPath
if (DBT_FILE_EXT.test(base)) {
for (let i = segments.length - 2; i >= 0; i--) {
const kind = DBT_DIR_KIND[segments[i].toLowerCase()]
if (kind) {
return `${base.replace(DBT_FILE_EXT, "")} ${kind}`
}
}
}
return base
}

/** 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}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When list targets the worktree root (no path argument or empty relative path), fileTarget() returns undefined and asString(rawTitle) also returns undefined (since path.relative(worktree, worktree) is ""). The ?? fallback in tool.ts then yields the original empty-string title, producing a blank UI label. Consider producing a fallback like "Listing ." when a file tool has a verb but neither a usable target nor a non-empty raw title.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/tool-label.ts, line 85:

<comment>When `list` targets the worktree root (no path argument or empty relative path), `fileTarget()` returns `undefined` and `asString(rawTitle)` also returns `undefined` (since `path.relative(worktree, worktree)` is `""`). The `??` fallback in `tool.ts` then yields the original empty-string title, producing a blank UI label. Consider producing a fallback like `"Listing ."` when a file tool has a verb but neither a usable target nor a non-empty raw title.</comment>

<file context>
@@ -0,0 +1,89 @@
+  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}`
+  }
+  // Non-file / rich-title tools: keep the title the tool already emitted.
</file context>

}
// Non-file / rich-title tools: keep the title the tool already emitted.
return fallback
}
Comment on lines +89 to +98
176 changes: 176 additions & 0 deletions packages/opencode/src/altimate/tool-source.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Authoritative classification of a tool call's origin, stamped onto the tool
* part's `state.metadata.source` so clients (chat webview, ...) render the right
* badge without re-deriving it from tool-name prefixes.
*
* - "builtin" — native opencode tools (read/glob/bash/...)
* - "altimate" — Altimate-provided tools (sql_*, schema_*, finops_*, ...) AND
* tools from the Datamates MCP server (Altimate-owned, just
* delivered over MCP)
* - "mcp" — third-party MCP tools
*
* Registry tools and MCP tools are resolved in separate loops (see
* `session/prompt.ts` resolveTools and `session/tools.ts` SessionTools.resolve),
* so each has its own classifier — but both loops stamp via the shared
* `stampRegistryToolSource` / `describeMcpTool` helpers below so they can't drift.
* The native `skill` tool is a further special case: it loads skills of varying
* origin, so its badge is classified per-call from the loaded skill's origin (see
* `skillToolSource`) rather than from the tool id.
*/
export type ToolSource = "builtin" | "altimate" | "mcp"

/**
* Where a registry tool comes from, declared at registration time so we don't
* have to infer ownership from the tool id:
* - "native" — a native opencode tool (read/glob/bash/...)
* - "altimate" — an Altimate-shipped tool registered directly in the registry
* - "external" — a user-authored custom tool or a third-party plugin tool
*
* Only "external" strictly needs to be declared: without it, the id-based
* fallback in `registryToolSource` treats every non-native id as Altimate, which
* over-claims ownership of custom/plugin tools (see `fromPlugin` in
* `tool/registry.ts`). Native and Altimate tools may omit it and rely on the id
* fallback.
*/
export type RegistryToolOrigin = "native" | "altimate" | "external"

/**
* Native opencode tool ids. This set is small and stable; every other tool in
* the registry is Altimate-provided, so new Altimate tools classify correctly
* with no per-tool maintenance here.
*/
const NATIVE_TOOL_IDS = new Set<string>([

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: The native batch tool would be misclassified as altimate

batch is a native opencode tool (Tool.define("batch", ...) in src/tool/batch.ts, registered in ToolRegistry.all() behind experimental.batch_tool), but it is absent from NATIVE_TOOL_IDS. As a result registryToolSource("batch") returns "altimate", contradicting the comment above the set that claims every non-listed registry tool is Altimate-provided. Adding "batch" to this set keeps its source badge correct.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

"invalid",
"question",
"bash",
"batch",
"read",
"glob",
"grep",
"list",
"edit",
"write",
"multiedit",
"task",
"webfetch",
"todowrite",
"todoread",
"websearch",
"codesearch",
"skill",
"apply_patch",
"lsp",
"plan_exit",
"plan_enter",
"StructuredOutput",
])

/** MCP client names that are Altimate-owned (Datamates as an MCP server). */
const ALTIMATE_MCP_PREFIXES = ["datamate"]

/**
* Classify a registry tool (never an MCP tool) as builtin vs Altimate. Prefers
* the origin declared at registration; only falls back to the id when a tool
* doesn't declare one (native + Altimate tools registered directly). "external"
* (user custom / third-party plugin) maps to the neutral "builtin" badge so the
* Altimate mark never over-claims tools we don't own.
*/
export function registryToolSource(id: string, origin?: RegistryToolOrigin): ToolSource {
switch (origin) {
case "external":
return "builtin"
case "altimate":
return "altimate"
case "native":
return "builtin"
}
return NATIVE_TOOL_IDS.has(id) ? "builtin" : "altimate"
Comment thread
ralphstodomingo marked this conversation as resolved.
}

/**
* Classify an MCP tool by its original (pre-sanitize) client name: Altimate
* (Datamates) vs third-party. Classifying from the real client name — rather than
* re-parsing the flattened `<client>_<tool>` key — avoids mislabeling a
* third-party server whose name sanitizes into a `datamate…`-prefixed key (e.g.
* client `datamate.ai` → key `datamate_ai_query`, whose first segment is
* `datamate`). Accepts the exact name, its plural (`datamates`), and a
* `datamate-…` prefixed client.
*/
export function mcpToolSource(clientName: string): ToolSource {
const client = clientName.toLowerCase()
return ALTIMATE_MCP_PREFIXES.some((p) => client === p || client === `${p}s` || client.startsWith(`${p}-`))
? "altimate"
: "mcp"
}

/**
* Classify a skill-load (the native `skill` tool) by the loaded skill's origin,
* which the skill tool reports on its metadata (see `tool/skill.ts`). Altimate
* ships its skills bundled with the CLI ("builtin" origin) — those wear the
* Altimate mark. User-authored global/project skills stay neutral so the badge
* doesn't over-claim third-party or personal skills. Origin arrives as `unknown`
* (client metadata), so anything other than "builtin" is treated as neutral.
*/
export function skillToolSource(origin: unknown): ToolSource {
return origin === "builtin" ? "altimate" : "builtin"
}

/** Mirror of `sanitize()` in `mcp/catalog.ts` — MCP keys are built from sanitized names. */
const sanitizeMcpName = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: MCP name sanitization is now duplicated in tool-source.ts, so a future change to McpCatalog.sanitize can silently make title prefix stripping wrong while key construction still uses the updated implementation. Reuse the catalog helper (or move sanitization to one shared utility) instead of maintaining a second regex.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/tool-source.ts, line 119:

<comment>MCP name sanitization is now duplicated in `tool-source.ts`, so a future change to `McpCatalog.sanitize` can silently make title prefix stripping wrong while key construction still uses the updated implementation. Reuse the catalog helper (or move sanitization to one shared utility) instead of maintaining a second regex.</comment>

<file context>
@@ -83,15 +115,62 @@ export function skillToolSource(origin: unknown): ToolSource {
 }
 
+/** Mirror of `sanitize()` in `mcp/catalog.ts` — MCP keys are built from sanitized names. */
+const sanitizeMcpName = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
+
 /**
</file context>


/**
* Best-effort readable title for an MCP tool call, from its `<client>_<tool>`
* key — e.g. "datamates_jira_get_issue" → "Jira Get Issue". Strips the client
* segment and Title-Cases the rest. When the original client name is known the
* segment is stripped by its exact sanitized length (robust to client names that
* themselves contain underscores, e.g. `datamate.ai` → `datamate_ai`); otherwise
* it falls back to splitting on the first `_`. (Richer per-call titles are the
* MCP server's job; this is the fallback so MCP rows aren't a bare snake_case id.)
*/
export function humanizeMcpTitle(key: string, clientName?: string): string {
const prefix = clientName ? `${sanitizeMcpName(clientName)}_` : ""
const withoutClient =
prefix && key.startsWith(prefix)
? key.slice(prefix.length)
: key.includes("_")
? key.slice(key.indexOf("_") + 1)
: key
const words = (withoutClient || key).split(/[_-]+/).filter(Boolean)
if (words.length === 0) return key
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")
}

/**
* A resolved registry tool, as seen by the two tool resolvers (`prompt.ts`
* resolveTools and `session/tools.ts` SessionTools.resolve).
*/
interface RegistryToolLike {
id: string
registrySource?: RegistryToolOrigin
}

/**
* Stamp the authoritative `source` badge onto a registry tool's output metadata.
* Shared by both tool resolvers so their per-call stamping cannot drift. The
* native `skill` tool is classified per-call from the loaded skill's origin
* (`state.metadata.skillOrigin`); every other tool from its declared/inferred
* origin.
*/
export function stampRegistryToolSource<T extends { metadata?: Record<string, any> }>(
output: T,
item: RegistryToolLike,
): T & { metadata: Record<string, any> & { source: ToolSource } } {
const metadata = output.metadata ?? {}
const source =
item.id === "skill" ? skillToolSource(metadata.skillOrigin) : registryToolSource(item.id, item.registrySource)
return { ...output, metadata: { ...metadata, source } }
}

/**
* Authoritative `source` badge + readable `title` for an MCP tool call, derived
* from the original client name and the flattened key. Shared by both tool
* resolvers so MCP stamping cannot drift.
*/
export function describeMcpTool(key: string, clientName: string): { source: ToolSource; title: string } {
return { source: mcpToolSource(clientName), title: humanizeMcpTitle(key, clientName) }
}
10 changes: 7 additions & 3 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ interface State {
export interface Interface {
readonly status: () => Effect.Effect<Record<string, Status>>
readonly clients: () => Effect.Effect<Record<string, MCPClient>>
readonly tools: () => Effect.Effect<Record<string, Tool>>
// altimate_change — carry the original (pre-sanitize) client name so tool-source classification
// works from the real name, not the flattened `<client>_<tool>` key (see altimate/tool-source).
readonly tools: () => Effect.Effect<Record<string, Tool & { client: string }>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly add: (name: string, mcp: ConfigMCPV1.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
Expand Down Expand Up @@ -1010,7 +1012,8 @@ export const layer = Layer.effect(
}

const tools = Effect.fn("MCP.tools")(function* () {
const result: Record<string, Tool> = {}
// altimate_change — values carry the original client name (see Interface.tools).
const result: Record<string, Tool & { client: string }> = {}
const s = yield* InstanceState.get(state)

const cfg = yield* cfgSvc.get()
Expand All @@ -1028,7 +1031,8 @@ export const layer = Layer.effect(
const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout)
for (const mcpTool of listed) {
const key = McpCatalog.sanitize(clientName) + "_" + McpCatalog.sanitize(mcpTool.name)
result[key] = McpCatalog.convertTool(mcpTool, client, timeout)
// altimate_change — attach the original client name for source classification downstream.
result[key] = Object.assign(McpCatalog.convertTool(mcpTool, client, timeout), { client: clientName })
}
}
return result
Expand Down
21 changes: 17 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ import { registerAltimateValidators } from "../altimate/validators"
registerAltimateValidators()
import { Config } from "../config/config"
import { Tracer } from "../altimate/observability/tracing"
// altimate_change — stamp an authoritative tool source + humanized MCP title
import { stampRegistryToolSource, describeMcpTool } from "../altimate/tool-source"
// altimate_change end
import { Telemetry } from "@/telemetry" // altimate_change — session telemetry

Expand Down Expand Up @@ -1607,6 +1609,9 @@ export namespace SessionPrompt {
messageID: input.processor.message.id,
})),
}
// altimate_change — stamp authoritative tool source so clients render the right badge.
// Shared with SessionTools.resolve (session/tools.ts) so the two resolvers can't drift.
const stamped = stampRegistryToolSource(output, item)
await Plugin.trigger(
"tool.execute.after",
{
Expand All @@ -1615,14 +1620,17 @@ export namespace SessionPrompt {
callID: ctx.callID,
args,
},
output,
stamped,
)
return output
return stamped
},
})
}

for (const [key, item] of Object.entries(await MCP.tools())) {
for (const [key, entry] of Object.entries(await MCP.tools())) {
// altimate_change — split the original client name off the model-facing tool object so it's
// used only for source classification and never leaks into the tool schema sent to the model.
const { client: clientName, ...item } = entry
const execute = item.execute
if (!execute) continue

Expand Down Expand Up @@ -1700,14 +1708,19 @@ export namespace SessionPrompt {
}

const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent)
// altimate_change — authoritative source + readable title from the original client name,
// shared with SessionTools.resolve (session/tools.ts) so the two resolvers can't drift.
const described = describeMcpTool(key, clientName)
const metadata = {
...(result.metadata ?? {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
source: described.source,
}

return {
title: "",
// altimate_change — MCP tools have no native title; give a readable label
title: described.title,
metadata,
output: truncated.content,
attachments: attachments.map((attachment) => ({
Expand Down
Loading
Loading