Skip to content

aitools: report the calling agent's own Databricks tooling in the User-Agent - #7442

Open
anton-107 wants to merge 1 commit into
mainfrom
aitools-agent-tooling-useragent
Open

anton-107 wants to merge 1 commit into
mainfrom
aitools-agent-tooling-useragent

Conversation

@anton-107

Copy link
Copy Markdown
Contributor

Summary

When a coding agent runs the CLI, the SDK already puts agent/<name> in the User-Agent. This PR adds aitools-agent/{plugin|skills|none} next to it: whether that agent has the Databricks plugin or Databricks skills installed in its own locations. Usage analysis can then see whether the calling agent already has the Databricks tooling. The existing aitools/<tool>_<version> pairs cannot answer that: they list the plugins of every tool on the machine, so a Claude Code plugin also shows up on requests made by Codex.

 root.New PersistentPreRunE
   installer.WithAiToolsInUserAgent
   installer.WithAiDevKitInUserAgent
+  installer.WithCallingAgentToolingInUserAgent
+    agents.CallingAgent()             // useragent.AgentProvider() -> registry agent; nil for no agent,
+                                      // several agents, or one the CLI cannot install into
+    (*Agent).DatabricksTooling(ctx)
+      plugin   <- databricks plugin in the agent's own manifest (Claude Code; a project/local
+                  scope record counts only inside its project)
+               <- or, for agents without a manifest reader (Codex, Copilot), a plugin recorded
+                  for this agent in the global CLI install state
+      skills   <- a databricks* dir in the agent's global skills dir, or in its project skills
+                  dir in the working directory or a parent up to the repository root
+      none
Calling agent What is installed Segment
none, several, or an AGENT=... value that is not a registry agent anything (not added)
Windsurf (SDK detects it, CLI cannot install into it) anything (not added)
Claude Code Claude Code plugin aitools-agent/plugin
Codex only the Claude Code plugin aitools-agent/none
Codex Codex plugin installed by databricks aitools install aitools-agent/plugin
Claude Code only a CLI state record (plugin since removed with claude plugin) aitools-agent/none
Claude Code only ~/.claude/skills/databricks-apps aitools-agent/skills
Claude Code, CLI run in repo/jobs repo/.claude/skills/databricks-apps, repo/.git aitools-agent/skills

The parent walk stops at the repository root, which is how Claude Code finds project skills. The shared ~/.databricks/aitools/skills store is not consulted: it holds the skills of every agent the CLI installed into, so it cannot say which agent has them. A Claude Code plugin that is installed but disabled in Claude's settings counts as installed.

This check differs from the one the existing Claude Code install hint uses, in both directions. The hint counts a plugin record in any scope and the shared store, but never looks at .claude/skills dirs; the segment does the opposite. So in one command the hint can stay silent while the segment says none (plugin for another project only), or fire while the segment says skills (project skills only).

Supporting changes in libs/aitools/agents/plugins.go:

  • The Claude Code manifest reader returns every databricks install record (version, scope, project path) from every marketplace entry (databricks@<marketplace>). Before, it kept the first matching entry in Go's random map order. DatabricksPluginVersion now reports the highest version across those records, so the existing aitools/claude-code_<version> pair is stable too.
  • A project path matches the working directory when the directory is the project or under it. Symlinks are resolved first. When that lexical check fails, the code compares file identity (os.SameFile) against the directory and each of its ancestors. This covers a different case on case-insensitive filesystems, Windows 8.3 short names, and bind mounts.

No telemetry event or proto changes. The segment travels in the User-Agent of every request the command makes.

Evidence

  • Before (upstream main's manifest reader, with this PR's TestDatabricksPluginVersionAcrossMarketplaces copied onto it, a manifest with databricks@a 0.2.9 and databricks@b 0.3.0, 200 reads):
    --- FAIL: TestDatabricksPluginVersionAcrossMarketplaces
        expected: map[string]int{"0.3.0":200}
        actual  : map[string]int{"0.2.9":174, "0.3.0":26}
    
    After: --- PASS: TestDatabricksPluginVersionAcrossMarketplaces
  • Before: upstream main sends no per-calling-agent signal: Codex calling the CLI on a machine with only the Claude Code plugin sends aitools/claude-code_0.2.9 and nothing about Codex.
    After: TestWithCallingAgentToolingInUserAgent passes its 8 cases, including
    CODEX_CI=1, Claude Code manifest has databricks 0.2.9  ->  UA contains "aitools-agent/none"
    CLAUDECODE=1, same manifest                             ->  UA contains "aitools-agent/plugin"
    
  • TestDatabricksTooling (per-agent locations, project-scope records inside/outside the project, stale CLI state, corrupt state, project skills at the repository root seen from a subdirectory, project skills above the repository root not counted), TestPluginInstallAppliesToBindMountedProjectPath (Linux, private mount namespace), TestPluginInstallAppliesToSymlinkedProjectPath, TestPluginInstallAppliesToProjectOutsideWorkingDir, and TestCallingAgent*: pass. TestPluginInstallAppliesToProjectPathInAnotherCase runs only where the temp filesystem folds case (macOS, Windows) and skipped on the Linux run.
  • go build ./..., ./task test-exp-aitools (760 unit, 25 acceptance), go test ./libs/telemetry/... ./cmd/root/... ./libs/aitools/... ./cmd/aitools/..., ./task lint-q (0 issues), ./task deadcode: pass.
  • Acceptance goldens that record the User-Agent (auth/credentials, cmd/api, bundle/user_agent, cmd/workspace/apps) pass unchanged: the harness clears the agent env vars, so no agent/ or aitools-agent/ segment appears.

Merge Danger

Door: two-way

Adds one User-Agent segment and changes no stored state or schema. Reverting removes the segment.

Blast Radius: User-Agent

  • Every command run under a detected registry agent gets one more User-Agent segment.
  • Before each such command, the CLI reads a few local files: Claude Code's plugin manifest, or the global CLI install state for Codex and Copilot. It also lists the agent's global skills dir and its project skills dirs from the working directory up to the repository root. Read errors count as "not installed" and never fail the command.
  • The aitools/claude-code_<version> pair can change value on machines where the plugin comes from more than one marketplace: it now always reports the highest version.
  • macOS and Windows path identity (case folding, drive letters, UNC paths, 8.3 names) follows from Go's filepath.Rel and os.SameFile behaviour. It was not run here; CI on those platforms runs the case-folding test.

This pull request and its description were written by Isaac.

…r-Agent

When a coding agent runs the CLI, the SDK already puts agent/<name> in the
User-Agent. Add aitools-agent/{plugin|skills|none} next to it: whether that
agent has the Databricks plugin or Databricks skills installed in its own
locations. The existing aitools/<tool>_<version> pairs list the plugins of
every tool on the machine, so they cannot answer this.

- agents.CallingAgent maps useragent.AgentProvider() to a registry agent.
- (*Agent).DatabricksTooling checks the agent's own plugin manifest (Claude
  Code; project/local scope records count only inside their project), or
  the global CLI install state for agents without a manifest reader, and
  databricks* dirs in the agent's global and project skills dirs (looked up
  from the working directory up to the repository root).
- The Claude Code manifest reader returns every databricks record from every
  marketplace entry; DatabricksPluginVersion reports the highest version
  instead of the first entry in random map order.
- Project paths match by lexical containment after resolving symlinks, then
  by file identity (case-insensitive filesystems, 8.3 names, bind mounts).

Co-authored-by: Isaac <[email protected]>
@eng-dev-ecosystem-bot

Copy link
Copy Markdown
Collaborator

✅ Integration test report

Commit: fd8c2c5

Run: 42826624596

Env ✅​pass 🙈​skip Time
✅​ aws linux-2core-8gb 271 15 7:22
✅​ aws-windows-latest-4core-16gb 273 13 5:30
✅​ azure linux-2core-8gb 270 15 5:26
✅​ azure-windows-latest-4core-16gb 272 13 4:56
✅​ gcp linux-2core-8gb 271 15 5:53
✅​ gcp-windows-latest-4core-16gb 273 13 5:27
Top 4 slowest tests (at least 2 minutes):
duration env testname
3:34 azure-windows-latest-4core-16gb TestAccept
3:16 aws-windows-latest-4core-16gb TestAccept
2:21 gcp-windows-latest-4core-16gb TestAccept
2:09 aws-windows-latest-4core-16gb TestImportDirWithOverwriteFlag

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. Nothing blocking; the inline comments are nits and nice-to-haves. Two larger points, neither blocking:

  • The install hint (hint.go, claudeToolingInstalled) and the new DatabricksTooling define "Claude has Databricks tooling" differently: the hint counts a plugin in any scope plus the shared ~/.databricks/aitools/skills store and never looks at .claude/skills; the segment does the opposite. The description already calls this out. Either point the hint at the new check, or leave a cross-reference comment so the split reads as deliberate rather than accidental.
  • The marketplace version fix changes the existing aitools/claude-code_<version> value on machines with the plugin from two marketplaces. Reasonable to keep here since the new code needs the full record list, but it does weaken the "revert removes only the segment" claim. A separate PR would give a cleaner revert story if you care about that.

Nit: the PR body uses Summary / Evidence / Merge Danger; the repo template (.github/PULL_REQUEST_TEMPLATE.md, enforced by the pr-checklist skill) is Changes / Why / Tests.

// databricks plugin install for the agent. Only the global state is read: the
// CLI installs plugins in project scope only for agents with SupportsProjectScope
// (see mapAgentScope in cmd/aitools), and those have a manifest reader.
func pluginRecorded(ctx context.Context, agentName string) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] This re-implements install-state reading from installer: its own .state.json constant (installer.stateFileName), its own home + CanonicalSkillsDir join (installer.GlobalSkillsDir), and a partial struct instead of installer.LoadState. Harmless today (migrateState only bumps the version), but a future state change would silently miss this reader.

The only production caller is WithCallingAgentToolingInUserAgent in installer, which already has all the pieces. Consider computing the Tooling value there and exposing only a plugin-record check from agents, or moving the state types into a leaf package both can import.

func projectDirs(cwd string) []string {
cwd = resolvePath(cwd)
dirs := []string{cwd}
root, err := folders.FindDirWithLeaf(cwd, ".git")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] Stopping at the nearest .git undercounts in two cases: a Claude Code project that is not a git repo (only cwd is checked, so <project>/.claude/skills/databricks* is missed when the CLI runs from a subdirectory), and a nested repo or submodule inside the agent's project. The doc says this matches Claude Code's own lookup; a source link would settle that (the comments rule asks for one when depending on an external tool's behaviour). If Claude Code walks further, walking to the filesystem root is bounded and has no false positives since ~/.claude/skills is already the global dir.

// and Windows, so it exercises the same identity check on Linux. Mounting needs
// a private mount namespace, so the test re-runs itself under `unshare -rm`
// (an unprivileged user namespace) and skips where those are not allowed.
func TestPluginInstallAppliesToBindMountedProjectPath(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] In CI this skips on all three Linux jobs ("unprivileged mount namespaces are not available"), and TestPluginInstallAppliesToProjectPathInAnotherCase skips there too (ext4 is case-sensitive). So the positive os.SameFile path is only covered on the macOS and Windows runners, and this file adds 57 lines of unshare / syscall.Mount machinery that never runs in CI. Either drop the Linux test or add a deterministic positive case.

// agent has them. Installs the CLI did not perform and that leave no trace in
// these locations (e.g. a Codex plugin installed with `codex` directly) read as
// ToolingNone. Read errors read as "not installed".
func (a *Agent) DatabricksTooling(ctx context.Context) Tooling {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] Per-command cost under Claude Code: the plugin manifest is now parsed three times (InstalledTools, here, MaybeHint), and cwd is EvalSymlinks-resolved once in projectDirs and again inside isSameOrUnder for every project/local record. Also, pluginRecorded reads and decodes the state file for agents with Plugin == nil (Cursor, OpenCode, Pi, Gemini, Goose, Kiro, Antigravity), where the answer is false by construction. Resolving cwd once and guarding the state read with a.Plugin != nil would remove most of it.

// a plugin removed with the agent's own CLI leaves a stale CLI state record
// behind, so the record is not consulted for these agents. A plugin that
// is installed but disabled in the agent's settings still counts;
// - for agents without a reader (Codex, Copilot), the plugin recorded for

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nice to have] The doc explains why the CLI state is ignored for Claude (stale after claude plugin uninstall), but for Codex and Copilot the state is the only source, so plugin here includes plugins since removed through the agent's own CLI. One sentence on that would tell analysts what plugin means for those agents.

// installed in more than one scope has several records.
type pluginInstall struct {
Version string `json:"version"`
// Scope is Claude Code's install scope: "managed", "user", "project" or

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] Source link for the installed_plugins.json scope / projectPath semantics? The comments rule asks for one when relying on an external tool's format; the SDK link at the top of calling.go is the pattern.

return useragent.InContext(ctx, callingAgentToolingUserAgentKey, toolingUserAgentValue(a.DatabricksTooling(ctx)))
}

func toolingUserAgentValue(t agents.Tooling) string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] Missing doc comment. This reads naturally as a String() method on agents.Tooling.

}
}

func TestCallingAgentFromEnv(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] These four TestCallingAgent* functions differ only in env input and expected result; a table fits the repo's testing rule. Also agents.CallingAgent().Name (and AgentForProvider(...).Name above) panics on nil instead of failing via require.NotNil. And TestAgentForProviderCoversRegistry hardcodes the SDK product names, so an SDK rename keeps it green while CallingAgent returns nil in production; driving it through the agents' env vars (CLAUDECODE, COPILOT_CLI, ...) would exercise the real mapping.

// first only about one time in eight), so a reader that keeps only the
// first databricks entry fails some of these reads.
got := map[string]int{}
for range 200 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] The 200-iteration loop re-reads the manifest from disk to defeat map order probabilistically. Asserting len(installs) == 2 from claudePluginInstalls (this package has access) proves the same property deterministically, with one plain DatabricksPluginVersion assertion on top. Same for the loop in calling_test.go.

ToolingPlugin
)

// DatabricksTooling reports the Databricks AI tooling installed for this agent

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[nit] This doc restates each branch of the 20-line body and has to move in lockstep with it. The non-obvious facts (the manifest is authoritative for Claude because claude plugin uninstall leaves a stale CLI record; the shared store cannot attribute skills to an agent) fit in about four lines. Same applies to the SetAgentEnv doc.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants