Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/agents/skill-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ bailian-gen bailian-finetune bailian-managed-agent bailian-web-search

- [ ] **整包装齐**:安装 / 缺 skill 文案主推 `bl skill init`;升级同步用 `bl skill update`(只更新已安装,不装新 skill);业务 skill **不**声明 `companions`
- [ ] **协议读取**:CRITICAL / references 可链 `../bailian-protocol/…`;若读不到 → 停止执行 `bl`,提示 `bl skill init`
- [ ] **调用归因**:业务 skill 与协议预检执行 `bl` 时必须带 Agent 归因前缀(见 `bailian-protocol`「Call attribution」);人类终端不要设这些变量
- [ ] **高风险确认**:统一由 `bailian-protocol` 定义;reference / leaf help 以 `risk: high` 明示风险,业务 skill 不得引导 Agent 自动补 `--yes`。遇到 exit code 7 / `requires_confirmation` 时停止执行并请求确认;目标或范围变化后重新确认
- [ ] **正常控制流**:`requires_confirmation` 不是 CLI bug,`assets/issue-reporting.md` 必须将 exit code 7 保持在 EXCLUDE 范围
- [ ] **软 hand-off**:兄弟业务 skill **只写 skill 名**;已安装则 Read,未安装则 `bl … --help` 或提示整包安装;**不要**把 `../bailian-gen/…` 等写成执行前提
Expand Down
37 changes: 25 additions & 12 deletions docs/agents/telemetry-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,18 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网
- `command`、`timestamp`、`durationMs`、`success`
- `cliVersion`、`nodeVersion`、`os`
- `authMethod`
- `callerType`(必填:`agent` 或 `unknown`);`callerType=agent` 时可选 `callerName`、`skillName`、`skillVersion`
- 失败时的 `errorMessage`、`exitCode`、`httpStatus`、`requestId`
- 安全 allowlist 过滤后的 `params`

调用方归因只认本次进程的 `BAILIAN_CALLER_*`(由 `readCaller` 归一化后写入事件):

- `BAILIAN_CALLER_TYPE` 为 `agent` / `ai_agent`(大小写不敏感)→ `callerType=agent`
- 未设置、空白或其他值 → `callerType=unknown`;不要把该字段当成人工
- `callerName` / `skillName` / `skillVersion` 仅在 `agent` 时发送;`kscli` 通常没有 skill 字段

新 CLI 每条进入 middleware 的命令事件都有 `callerType`。旧 CLI 没有该字段。看板只统计带 `callerType` 的事件,不能把字段缺失混进分母。`unknown` 只表示未识别为 agent,不是人工。新 CLI + 旧 skill / Agent 漏设变量时,会落进 `unknown`。`BAILIAN_CALLER_*` 是调用方声明,不是 `Identity`,不得用来猜 `bl` / `kscli`。

参数默认不上传,只有 `packages/core/src/telemetry/tracker.ts` 的 `PARAM_ALLOWLIST` 中字段会进入事件。不得加入 prompt、凭证、文件路径、URL、账号/租户/工作空间 ID 或其他用户内容。

`page` 仅记录有限数值页码;Webhook 等命令使用的不透明字符串游标不得进入本地日志或远端 AEM,即使字符串内容看起来是数字。
Expand All @@ -113,16 +122,16 @@ source-config 只用于百炼 / DashScope API 侧消费,不发送到通用网

AEM 映射:

| AEM 字段 | 内容 |
| ---------- | ------------------------------------------------------------------ |
| event name | 命令路径 |
| `et` | `EXP` |
| `ext` | 除 `command`、`params` 外的结构化事件字段,包含失败时的 `exitCode` |
| `c1` | allowlist 参数 |
| `c2` | `success` / `failure` |
| `c3` | HTTP status |
| `c4` | 错误文案,最多 500 字符 |
| `c5` | request ID |
| AEM 字段 | 内容 |
| ---------- | -------------------------------------------------------------------------------- |
| event name | 命令路径 |
| `et` | `EXP` |
| `ext` | 除 `command`、`params` 外的结构化事件字段,含 `callerType` 与失败时的 `exitCode` |
| `c1` | allowlist 参数 |
| `c2` | `success` / `failure` |
| `c3` | HTTP status |
| `c4` | 错误文案,最多 500 字符 |
| `c5` | request ID |

远端发送是 best-effort,不得阻塞命令或改变退出码。正常退出最多等待 1 秒,SIGINT 最多等待 500 ms。

Expand All @@ -140,6 +149,7 @@ AEM 映射:

- [ ] 同时核对 `packages/core/src/client/http.ts`、`mcp.ts`、`instrumented-fetch.ts`、`client.ts` 与 `files/upload.ts`
- [ ] 产品身份必须来自 `Identity`;不得从命令路径、环境变量或 `process.argv` 猜测
- [ ] `BAILIAN_CALLER_*` 是调用方声明,不得用来猜产品身份(`bl` / `kscli`)
- [ ] `bl` 与 `kscli` 必须分别验证 `binName`、`clientName`、`version`
- [ ] OSS、结果文件、npm、二进制和 Skill 下载不得为了业务渠道统计新增 source-config
- [ ] 改 URL / host 范围时同时执行 [URL / 渠道变更](url-change.md) 清单
Expand All @@ -148,6 +158,7 @@ AEM 映射:

- [ ] 更新 `TrackingEvent`、`createTrackingEvent()` 与 `buildRemoteAemOptions()` 的字段映射
- [ ] 本地 JSONL 与远端 AEM 必须基于同一结构化事件,不能维护两套字段口径
- [ ] 调用方字段经 `readCaller` → `TrackingDeps.caller` → 事件;仅显式 `agent` / `ai_agent` 为 `agent`,其余为 `unknown`
- [ ] 成功与失败均覆盖;遥测异常必须静默且不改变业务退出码
- [ ] runtime 本地语义错误应记录 `exitCode`;新增字段默认随 AEM `ext` 上报,无需占用新的 `c1`—`c5`
- [ ] 检查 `DO_NOT_TRACK=1` 与 `telemetry: false` 两个关闭入口
Expand All @@ -157,15 +168,17 @@ AEM 映射:

```sh
rg -n "trackingHeaders|x-dashscope-source-config|User-Agent" packages --glob '*.ts'
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions" packages/core packages/runtime --glob '*.ts'
rg -n "trackCommandExecution|PARAM_ALLOWLIST|buildRemoteAemOptions|readCaller" packages/core packages/runtime --glob '*.ts'
vp check
vp test packages/core/tests packages/commands/tests/e2e/auth.e2e.test.ts
vp test packages/core/tests packages/runtime/tests/telemetry-stage.test.ts packages/commands/tests/e2e/auth.e2e.test.ts
```

## 常见漏点

- ✗ 只看 AEM 命令事件,误以为它能替代网关侧请求渠道统计
- ✗ 把 `authMethod` 当成实际凭证来源;它只是命令声明的鉴权域
- ✗ 把旧 CLI「没有 `callerType`」或「`callerType=unknown`」当成人工占比
- ✗ 用 `BAILIAN_CALLER_*` 猜测产品身份,或根据 TTY / 安装目录反推调用方
- ✗ 新增 bypass `fetch` 后漏掉应由网关消费的 source-config,或把它发给 OSS / npm / 第三方下载地址
- ✗ 只改 `bl` 入口,导致 `kscli` 的产品名或版本标签错误
- ✗ 把帮助、版本或参数校验失败算进“全部命令”;这些路径当前没有进入 telemetry middleware
59 changes: 59 additions & 0 deletions packages/core/src/telemetry/caller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/** Parse BAILIAN_CALLER_*. Only an explicit agent is `agent`; everything else is `unknown`. */

export type CallerAttribution =
| { callerType: "unknown" }
| {
callerType: "agent";
callerName: string;
skillName?: string;
skillVersion?: string;
};

const CALLER_TYPE_ENV = "BAILIAN_CALLER_TYPE";
const CALLER_NAME_ENV = "BAILIAN_CALLER_NAME";
const SKILL_NAME_ENV = "BAILIAN_SKILL_NAME";
const SKILL_VERSION_ENV = "BAILIAN_SKILL_VERSION";

const NAME_PATTERN = /^[a-z0-9][a-z0-9.-]{0,63}$/;
const SKILL_NAME_PATTERN = /^bailian-[a-z0-9-]{1,64}$/;
const SKILL_VERSION_PATTERN = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]{1,20})?$/;

function readTrimmed(env: NodeJS.ProcessEnv, key: string): string | undefined {
const raw = env[key];
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

/** Normalize host-reported name to a stable id, or `unknown`. */
function normalizeCallerName(raw: string | undefined): string {
if (!raw) return "unknown";
const normalized = raw.toLowerCase().replace(/[\s_]+/g, "-");
return NAME_PATTERN.test(normalized) ? normalized : "unknown";
}

/** Env-only attribution; no TTY or install-path heuristics. */
export function readCaller(env: NodeJS.ProcessEnv): CallerAttribution {
const callerType = readTrimmed(env, CALLER_TYPE_ENV)?.toLowerCase();
if (callerType !== "agent" && callerType !== "ai_agent") {
return { callerType: "unknown" };
}

const skillNameRaw = readTrimmed(env, SKILL_NAME_ENV)?.toLowerCase();
const skillName =
skillNameRaw !== undefined && SKILL_NAME_PATTERN.test(skillNameRaw) ? skillNameRaw : undefined;
const skillVersionRaw = readTrimmed(env, SKILL_VERSION_ENV);
const skillVersion =
skillName !== undefined &&
skillVersionRaw !== undefined &&
SKILL_VERSION_PATTERN.test(skillVersionRaw)
? skillVersionRaw
: undefined;

return {
callerType: "agent",
callerName: normalizeCallerName(readTrimmed(env, CALLER_NAME_ENV)),
...(skillName !== undefined ? { skillName } : {}),
...(skillVersion !== undefined ? { skillVersion } : {}),
};
}
14 changes: 14 additions & 0 deletions packages/core/src/telemetry/event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AuthRequirement } from "../types/command.ts";
import type { CallerAttribution } from "./caller.ts";

export interface TrackingEvent {
command: string;
Expand All @@ -14,6 +15,11 @@ export interface TrackingEvent {
os: string;
authMethod?: AuthRequirement;
params?: Record<string, unknown>;
/** Always present on new CLI events: `agent` or `unknown`. */
callerType: CallerAttribution["callerType"];
callerName?: string;
skillName?: string;
skillVersion?: string;
}

export function createTrackingEvent(opts: {
Expand All @@ -24,6 +30,7 @@ export function createTrackingEvent(opts: {
cliVersion: string;
authMethod?: AuthRequirement;
params?: Record<string, unknown>;
caller: CallerAttribution;
}): TrackingEvent {
const event: TrackingEvent = {
command: opts.command,
Expand All @@ -33,8 +40,15 @@ export function createTrackingEvent(opts: {
cliVersion: opts.cliVersion,
nodeVersion: process.version,
os: process.platform,
callerType: opts.caller.callerType,
};

if (opts.caller.callerType === "agent") {
event.callerName = opts.caller.callerName;
if (opts.caller.skillName !== undefined) event.skillName = opts.caller.skillName;
if (opts.caller.skillVersion !== undefined) event.skillVersion = opts.caller.skillVersion;
}

if (opts.authMethod) {
event.authMethod = opts.authMethod;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
export type { CallerAttribution } from "./caller.ts";
export { readCaller } from "./caller.ts";
export type { TrackingEvent } from "./event.ts";
export { createTrackingEvent } from "./event.ts";
export { localSink, remoteSink, flushTelemetry } from "./sink.ts";
export { trackCommandExecution } from "./tracker.ts";
export type { TrackingDeps } from "./tracker.ts";
4 changes: 4 additions & 0 deletions packages/core/src/telemetry/tracker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Identity, Settings } from "../config/schema.ts";
import { BailianError } from "../errors/base.ts";
import type { AuthRequirement } from "../types/command.ts";
import type { CallerAttribution } from "./caller.ts";
import { createTrackingEvent } from "./event.ts";
import { localSink, remoteSink } from "./sink.ts";

Expand Down Expand Up @@ -89,6 +90,8 @@ export interface TrackingDeps {
identity: Identity;
settings: Settings;
authMethod?: AuthRequirement;
/** From `readCaller(process.env)` in telemetryStage. */
caller: CallerAttribution;
}

export async function trackCommandExecution(
Expand Down Expand Up @@ -133,6 +136,7 @@ export async function trackCommandExecution(
cliVersion: deps.identity.version,
authMethod: deps.authMethod,
params: extractParams(flags),
caller: deps.caller,
});

// Fire-and-forget — never block the CLI exit or mask errors
Expand Down
142 changes: 142 additions & 0 deletions packages/core/tests/telemetry-caller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { expect, test } from "vite-plus/test";
import { readCaller } from "../src/telemetry/caller.ts";

test.each([
{ label: "missing", env: {} },
{ label: "blank", env: { BAILIAN_CALLER_TYPE: " " } },
{ label: "explicit human", env: { BAILIAN_CALLER_TYPE: "human" } },
{ label: "illegal false", env: { BAILIAN_CALLER_TYPE: "false" } },
{ label: "illegal ai-agent", env: { BAILIAN_CALLER_TYPE: "ai-agent" } },
{ label: "illegal zero", env: { BAILIAN_CALLER_TYPE: "0" } },
])("maps $label to unknown without agent fields", ({ env }) => {
expect(readCaller(env)).toEqual({ callerType: "unknown" });
});

test.each([
{ label: "agent", type: "agent" },
{ label: "ai_agent", type: "ai_agent" },
{ label: "Agent", type: "Agent" },
{ label: "AI_AGENT", type: "AI_AGENT" },
{ label: "padded agent", type: " agent " },
])("accepts $label as agent", ({ type }) => {
expect(
readCaller({
BAILIAN_CALLER_TYPE: type,
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "bailian-cli",
BAILIAN_SKILL_VERSION: "2.5.0",
}),
).toEqual({
callerType: "agent",
callerName: "cursor",
skillName: "bailian-cli",
skillVersion: "2.5.0",
});
});

test("normalizes callerName and falls back to unknown", () => {
expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: " Claude Code ",
}),
).toEqual({ callerType: "agent", callerName: "claude-code" });

expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "qwen_code",
}),
).toEqual({ callerType: "agent", callerName: "qwen-code" });

expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "bad/name!",
}),
).toEqual({ callerType: "agent", callerName: "unknown" });

expect(readCaller({ BAILIAN_CALLER_TYPE: "agent" })).toEqual({
callerType: "agent",
callerName: "unknown",
});
});

test("drops illegal skillName and only keeps version when skillName is valid", () => {
expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "not-bailian",
BAILIAN_SKILL_VERSION: "2.5.0",
}),
).toEqual({ callerType: "agent", callerName: "cursor" });

expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "bailian-cli",
BAILIAN_SKILL_VERSION: "not-a-version",
}),
).toEqual({
callerType: "agent",
callerName: "cursor",
skillName: "bailian-cli",
});

expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "bailian-cli",
BAILIAN_SKILL_VERSION: "2.5.0-beta.1",
}),
).toEqual({
callerType: "agent",
callerName: "cursor",
skillName: "bailian-cli",
skillVersion: "2.5.0-beta.1",
});
});

test("lowercases skillName before validation", () => {
expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "Bailian-CLI",
BAILIAN_SKILL_VERSION: "2.5.0",
}),
).toEqual({
callerType: "agent",
callerName: "cursor",
skillName: "bailian-cli",
skillVersion: "2.5.0",
});

expect(
readCaller({
BAILIAN_CALLER_TYPE: "agent",
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "BAILIAN-GEN",
BAILIAN_SKILL_VERSION: "2.5.0",
}),
).toEqual({
callerType: "agent",
callerName: "cursor",
skillName: "bailian-gen",
skillVersion: "2.5.0",
});
});

test("ignores agent detail fields when callerType is not agent", () => {
expect(
readCaller({
BAILIAN_CALLER_TYPE: "human",
BAILIAN_CALLER_NAME: "cursor",
BAILIAN_SKILL_NAME: "bailian-cli",
BAILIAN_SKILL_VERSION: "2.5.0",
}),
).toEqual({ callerType: "unknown" });
});
Loading
Loading