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
2 changes: 2 additions & 0 deletions docs/guide/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ With [usage reporting](reporting.md) on, every run publishes a privacy-preservin

A record carries only: the run id, workflow name, phase (`start` or `end`), driver kind, status, the end the run reached (when it reached one), the failure code of a run that failed without reaching an end, when it is a code teamai sets, such as `loop_exhausted` for a loop that ran out or `tool_unavailable` when the local driver cannot find the tool (never its message), start and end times, duration, per-step entries (step path such as `check.classify`, agent, tool, model, outcome, tokens — a mixed-agent run records each step's own fields; `tool` is kept only when it is one of the local driver's tools, and `model` is the name the driver reported, kept only when it is shaped like a model name), summary step counts (succeeded, failed, skipped, cancelled), the cumulative settled-attempt count, and the publishing username. Every entry has its `pass` number, and a step that a loop ran more than once has one entry per pass. An item of a `for_each` counts the times that item ran, so its `pass` can be lower than its step's when an earlier pass had fewer items. A cancelled step's entry has the outcome `cancelled`, and a skipped step's has none. The counts cover each step once, as the run ended. Steps of a sub-workflow that never started are not counted. Prompts, command text, input values, `env` values, outputs, diffs, secret values, local workspace paths, session ids, and hostnames have no field to land in — the record is built from an allowlist, not filtered from the run state.

An entry's `agent` is what that pass ran as, as observed. It holds the step's `agent:` definition and how the tool got it (`main` when the tool reads its own agent file, `prompt` when the instructions are prepended), the tool and its version, the model the launch asked for and the one the tool reported, the team commit the checkout last pulled and whether a delivered file was edited since, the project commit, the TeamAI version, and the driver kind. A value nobody observed is left out. `fingerprint` hashes the definition, tool, model, effort and team commit, so two passes with the same fingerprint ran with the same recorded parts. It is left out when the model or the team commit is unknown, or a delivered file was edited. A pass nothing observed, and a record from an older TeamAI, carry only the definition's name. While a step runs, the tool it starts, and any session that tool starts in the same checkout, skips the session-start pull when the checkout's last pull delivered completely to that tool, so the step keeps the team commit it records.

The end record is published by the `next` that settles the run. A publication that fails — no remote, a busy reports lock, a rejected push — is queued in this scope's data directory and retried on the next `teamai pull`. The `start` and `next` envelopes then carry `record: pending` instead of `record: published`. The run itself is unaffected: a machine that dies after starting a run leaves it visible as started but not ended.

Run ids are `r` plus 16 hex digits. Runs created before this scheme used 8 hex digits and still resolve.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/zh-CN/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ teamai workflow report <run> --json '{"attempt":"<attempt>","revision":1,"outcom

记录只包含:run id、工作流名、阶段(`start` 或 `end`)、驱动的 kind、状态、运行到达的 end(到达时才有)、未到达 end 就失败的运行的失败代码(仅限 teamai 设定的代码,如循环用完次数时的 `loop_exhausted`,或本地驱动找不到工具时的 `tool_unavailable`,不含失败消息)、开始和结束时间、时长、逐步条目(step 路径如 `check.classify`、agent、tool、model、outcome、tokens——混合 agent 的运行会分别记录每步自己的字段;`tool` 仅在是本地驱动的工具之一时保留,`model` 是驱动报告的名称,仅在形如模型名时保留)、步骤汇总计数(succeeded、failed、skipped、cancelled)、累计已结算 attempt 数,以及发布者用户名。每条条目都带 `pass` 编号,循环运行了多次的步骤每个 pass 各有一条条目。`for_each` 的项按该项自己运行的次数计 `pass`,所以前面某个 pass 的项较少时,项的 `pass` 可能比所属步骤的小。被取消步骤的条目 outcome 为 `cancelled`,被跳过的步骤没有 outcome。汇总计数按运行结束时的状态,每个步骤只计一次。从未开始的子工作流里的步骤不计入。提示词、命令文本、输入值、`env` 值、输出、diff、秘密值、本地工作区路径、会话 id 和主机名没有可落地的字段——记录由白名单构建,而不是从运行状态里过滤。

条目的 `agent` 是该 pass 实际运行时观察到的组合:步骤的 `agent:` 定义及工具如何拿到它(工具自己读取 agent 文件时为 `main`,指令拼接到提示词前时为 `prompt`)、工具及其版本、启动时请求的模型和工具报告的模型、检出最近一次 pull 的团队提交以及之后是否改过已下发的文件、项目提交、TeamAI 版本和驱动的 kind。没有观察到的值不写。`fingerprint` 对定义、工具、模型、effort 和团队提交做哈希,fingerprint 相同的两个 pass 记录下来的这些部分相同。模型或团队提交未知,或改过已下发的文件时,不写 `fingerprint`。没有任何观察的 pass,以及旧版 TeamAI 写的记录,只带定义的名称。步骤运行期间,如果该检出最近一次 pull 已完整下发到步骤启动的工具,该工具以及它在同一检出中启动的会话都会跳过会话开始时的 pull,步骤因此保持它记录的团队提交。

结束记录由结算该运行的那次 `next` 发布。发布失败时——没有远端、reports 锁被占用、push 被拒——记录会进入当前 scope 数据目录的本地队列,在下一次 `teamai pull` 时重试。此时 `start`、`next` 的返回会带 `record: pending` 而不是 `record: published`。运行本身不受影响:一台机器在运行开始后崩溃,该运行会以"已开始未结束"的状态保持可见。

Run id 是 `r` 加 16 位十六进制。此方案之前创建的运行用 8 位十六进制,仍然可以解析。
Expand Down
262 changes: 262 additions & 0 deletions src/__tests__/agent-observation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
isNameShaped,
observeLaunch,
parseReportedAgent,
readRecordedAgent,
settleObservation,
type ReportedAgent,
} from '../agent-observation.js';
import { getCurrentVersion } from '../package-info.js';
import type { LocalConfig } from '../types.js';
import { commitFile, fakeClaude, git, initRepo, recordDelivery } from './helpers/delivered-checkout.js';

const cleanups: Array<() => void> = [];

afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
});

const FULL = 'a'.repeat(40);

describe('settleObservation (#1176)', () => {
const complete: ReportedAgent = {
tool: { version: '2.1.301' },
model: { requested: 'claude-opus-4-1', alias: 'opus' },
team: { revision: FULL, localChanges: false },
project: { commit: 'b'.repeat(40), dirty: false },
};
const settle = (agent: ReportedAgent | undefined, overrides: Partial<Parameters<typeof settleObservation>[0]> = {}) =>
settleObservation({ agent, tool: 'claude', driver: { kind: 'local' }, ...overrides }, 'reviewer');

it('stamps what the engine owns and derives tool.name and model.reported from the flat report', () => {
const settled = settle({ ...complete, definition: { applied: 'main' } }, { model: 'claude-opus-4-1-20250805' });

expect(settled).toMatchObject({
v: 1,
definition: { name: 'reviewer', applied: 'main' },
tool: { name: 'claude', version: '2.1.301' },
model: { requested: 'claude-opus-4-1', reported: 'claude-opus-4-1-20250805', alias: 'opus' },
teamai: getCurrentVersion(),
driver: 'local',
});
expect(settled?.fingerprint).toMatch(/^[0-9a-f]{64}$/);
});

it('is undefined without a reported agent or a name-shaped tool', () => {
expect(settle(undefined)).toBeUndefined();
expect(settle(complete, { tool: '/usr/local/bin/claude' })).toBeUndefined();
});

it('has no definition on a step without agent:', () => {
const settled = settleObservation({ agent: { ...complete, definition: { applied: 'main' } }, tool: 'claude', driver: { kind: 'local' } }, undefined);

expect(settled).not.toHaveProperty('definition');
});

it('drops values not shaped like a name and ignores engine-owned keys a driver sends', () => {
const settled = settle(parseReportedAgent({
...complete,
tool: { version: '/usr/bin/claude' },
model: { requested: 'see https://example.com' },
effort: { requested: 'high effort please', reported: 'high' },
team: { revision: 'not-a-commit', localChanges: false },
teamai: '0.0.1',
driver: 'spoofed',
definition: { name: 'spoofed', applied: 'prompt' },
fingerprint: 'f'.repeat(64),
}) as ReportedAgent);

expect(settled).toEqual({
v: 1,
definition: { name: 'reviewer', applied: 'prompt' },
tool: { name: 'claude' },
effort: { reported: 'high' },
project: { commit: 'b'.repeat(40), dirty: false },
teamai: getCurrentVersion(),
driver: 'local',
});
});

it('takes the tool and the reported model from the agent a driver sends without the flat fields, and the flat ones first', () => {
const nested = parseReportedAgent({ ...complete, tool: { name: 'codex', version: '0.46.0' }, model: { reported: 'gpt-5' } }) as ReportedAgent;

expect(settle(nested, { tool: undefined })).toMatchObject({ tool: { name: 'codex', version: '0.46.0' }, model: { reported: 'gpt-5' } });
const flat = settle(nested, { tool: 'claude', model: 'claude-opus-4-1' });
expect(flat?.tool).toEqual({ name: 'claude' });
expect(flat?.model).toMatchObject({ reported: 'claude-opus-4-1' });
});

it('keeps a model with a provider prefix, and no tool, version or effort that holds a path', () => {
const settled = settle({ ...complete, tool: { version: 'home/alice/bin/claude' }, effort: { requested: 'team/high' } },
{ model: 'anthropic/claude-sonnet-4.6@2026' });

expect(settled).toMatchObject({ tool: { name: 'claude' }, model: { reported: 'anthropic/claude-sonnet-4.6@2026' } });
expect(settled?.tool).not.toHaveProperty('version');
expect(settled).not.toHaveProperty('effort');
expect(settle(complete, { tool: 'bin/claude' })).toBeUndefined();
});

it('fingerprints equal parts alike, whatever their key order', () => {
const reordered: ReportedAgent = {
project: { dirty: true, commit: 'c'.repeat(40) },
team: { localChanges: false, revision: FULL },
model: { alias: 'opus', requested: 'claude-opus-4-1' },
tool: { version: '2.1.301' },
};

expect(settle(reordered, { driver: { kind: 'other' } })?.fingerprint).toBe(settle(complete)?.fingerprint);
});

it('fingerprints a different model differently, and a requested model unlike the same reported one', () => {
const base = settle(complete)?.fingerprint;

expect(settle({ ...complete, model: { requested: 'claude-sonnet-4-5', alias: 'opus' } })?.fingerprint).not.toBe(base);
expect(settle({ ...complete, model: undefined }, { model: 'claude-opus-4-1' })?.fingerprint)
.not.toBe(settle({ ...complete, model: { requested: 'claude-opus-4-1' } })?.fingerprint);
});

it('writes no fingerprint without a model, a team, or with local changes', () => {
expect(settle({ ...complete, model: { alias: 'opus' } })).not.toHaveProperty('fingerprint');
expect(settle({ ...complete, team: undefined })).not.toHaveProperty('fingerprint');
expect(settle({ ...complete, team: { revision: FULL, localChanges: true } })).not.toHaveProperty('fingerprint');
});
});

describe('parseReportedAgent (#1176)', () => {
it('rejects a wrong JSON type with the field it names', () => {
expect(parseReportedAgent('reviewer')).toBe('agent must be an object');
expect(parseReportedAgent({ tool: { version: 2 } })).toBe('agent.tool.version must be a string');
expect(parseReportedAgent({ team: { revision: FULL, localChanges: 'no' } })).toBe('agent.team.localChanges must be a boolean');
});
});

describe('readRecordedAgent (#1176)', () => {
it('reads an older string agent as its definition', () => {
expect(readRecordedAgent('reviewer')).toEqual({ definition: { name: 'reviewer' } });
});

it('reads an observation as it is, and anything else as nothing', () => {
const observation = settleObservation({ agent: {}, tool: 'claude', driver: { kind: 'local' } }, 'reviewer');

expect(readRecordedAgent(observation)).toEqual(observation);
expect(readRecordedAgent({ definition: { name: 'reviewer' } })).toEqual({ definition: { name: 'reviewer' } });
expect(readRecordedAgent({ v: 2, tool: { name: 'claude' } })).toBeUndefined();
expect(readRecordedAgent(7)).toBeUndefined();
});
});

describe('isNameShaped', () => {
it('accepts model names and rejects paths, URLs and text', () => {
expect(isNameShaped('anthropic/claude-sonnet-4.6@2026')).toBe(true);
expect(isNameShaped('2.1.301')).toBe(true);
for (const value of ['/usr/bin/claude', 'C:\\claude', 'https://x', '../x', 'two words', '']) {
expect(isNameShaped(value)).toBe(false);
}
});
});

describe('observeLaunch (#1176)', () => {
function checkout(): { config: LocalConfig; root: string; project: string; rev: string; skill: string; agent: string } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-observe-'));
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
const team = path.join(root, 'team');
const project = path.join(root, 'project');
initRepo(team);
initRepo(project);
const rev = commitFile(team, 'agents/reviewer.yaml');
commitFile(project, 'README.md');
const skill = path.join(project, '.claude', 'skills', 'review', 'SKILL.md');
const agent = path.join(project, '.claude', 'agents', 'reviewer.md');
for (const file of [skill, agent]) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${path.basename(file)}\n`);
}
const config: LocalConfig = {
repo: { localPath: team, remote: 'local', kind: 'git' },
username: 'tester',
scope: 'project',
additionalRoles: [],
projectRoot: project,
dataHome: path.join(root, 'data'),
};
return { config, root, project, rev, skill, agent };
}

const agentModels = { reviewer: { claude: { step: 'alias', model: 'claude-opus-4-1', effort: 'high', alias: 'opus' } } };

it('observes a complete delivery, the requested model, the tool version and the project head, and no effort from the agent file', async () => {
const { config, root, project, rev, skill, agent } = checkout();
cleanups.push(fakeClaude(root, '2.1.301'));
await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['claude'], files: [skill, agent], agentModels });

expect(await observeLaunch({
config, tool: 'claude', cwd: project,
definition: { stem: 'reviewer', file: agent, applied: 'main' },
})).toEqual({
definition: { applied: 'main' },
tool: { version: '2.1.301' },
model: { requested: 'claude-opus-4-1', alias: 'opus' },
team: { revision: rev, localChanges: false },
project: { commit: git(project, 'rev-parse', 'HEAD'), dirty: false },
});
});

it('reports local changes after a delivered file is edited', async () => {
const { config, project, rev, skill } = checkout();
await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['claude'], files: [skill] });
fs.appendFileSync(skill, 'edited\n');

expect((await observeLaunch({ config, tool: 'claude', cwd: project })).team).toEqual({ revision: rev, localChanges: true });
});

it('requests no model after the deployed agent file is edited', async () => {
const { config, project, rev, agent } = checkout();
await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['claude'], files: [agent], agentModels });
fs.writeFileSync(agent, 'model: another\n');

const observed = await observeLaunch({ config, tool: 'claude', cwd: project, definition: { stem: 'reviewer', file: agent, applied: 'main' } });

expect(observed).not.toHaveProperty('model');
expect(observed).not.toHaveProperty('effort');
});

it('requests the model the launch passes on argv for a definition applied as prompt', async () => {
const { config, project, rev, agent } = checkout();
const codexModels = { reviewer: { codex: { step: 'alias', model: 'gpt-5', alias: 'smart', effort: 'high' } } };
await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['codex'], files: [agent], agentModels: codexModels });
const launch = { config, tool: 'codex', cwd: project };

expect(await observeLaunch({ ...launch, definition: { stem: 'reviewer', file: agent, applied: 'prompt', model: 'gpt-5' } }))
.toMatchObject({ definition: { applied: 'prompt' }, model: { requested: 'gpt-5', alias: 'smart' } });
const edited = await observeLaunch({ ...launch, definition: { stem: 'reviewer', file: agent, applied: 'prompt', model: 'gpt-4' } });
expect(edited.model).toEqual({ requested: 'gpt-4' });
expect(edited).not.toHaveProperty('effort');
});

it('names no team for a record whose push base is another commit', async () => {
const { config, project, rev } = checkout();
const later = commitFile(config.repo.localPath, 'agents/other.yaml');
await recordDelivery(config, { rev: rev.slice(0, 7), targets: ['claude'], pushBaseRevs: [later] });

expect(await observeLaunch({ config, tool: 'claude', cwd: project })).not.toHaveProperty('team');
});

it('never reports claude\'s version under another tool\'s name', async () => {
const { config, root, project } = checkout();
cleanups.push(fakeClaude(root, '2.1.301'));

expect(await observeLaunch({ config, tool: 'tclaude', cwd: project })).not.toHaveProperty('tool');
});

it('leaves out what it cannot observe outside a checkout', async () => {
const { config, root } = checkout();
const outside = path.join(root, 'outside');
fs.mkdirSync(outside);

expect(await observeLaunch({ config, tool: 'tclaude', cwd: outside })).toEqual({});
});
});
5 changes: 3 additions & 2 deletions src/__tests__/e2e/workflow-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,12 @@ describe('local prompt workflow CLI', () => {
const fs = require('node:fs');
fs.mkdirSync(require('node:path').dirname(process.env.TEAMAI_WORKFLOW_RESULT), { recursive: true });
fs.writeFileSync(process.env.TEAMAI_WORKFLOW_RESULT, JSON.stringify({ output: { ok: true } }));
process.stdout.write(JSON.stringify({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' }) + '\\n');
process.stdout.write(JSON.stringify({
model: 'fake-claude',
type: 'result',
session_id: 'fake-session',
usage: { input_tokens: 4, output_tokens: 2, cache_read_input_tokens: 1 }
}));
}) + '\\n');
`);
fs.chmodSync(fake, 0o755);
});
Expand Down
Loading
Loading