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 changelog.d/3438.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!-- ### Fixed -->
- **fix(agent-health): 日次確認の報告にツールごとの起動モデルを残す** (#3438): pass の check は evidence を残さないため、どのモデルで確かめたかが報告から読めなかった。レポートの各ツールに `launchedModel`(画面・hook から読めたモデル。読めなければ「不明」。opencode / opencode-v2 は複製した `model.json` の `recent[0]` も並べる)を足し、要約の最後に「起動したモデル:」を出す。この項目の無い古いレポートは「不明」として読む。
4 changes: 2 additions & 2 deletions docs/module-reference.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions docs/user-guide/agent-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,28 @@ pass 38・fail 0・skip 25(検査の定義が無い 5・このツールは、

`--tools` / `--only` で外したマスも表には `skip(今回の実行の対象外)` として数える(理由の行には出さない)。

最後に、起動したツールごとのモデルを 1 行ずつ出す(Issue #3438。pass の check は evidence を残さないので、どのモデルで確かめたかはここで読む)。

```
起動したモデル:
- claude: Haiku 4.5(画面)
- antigravity: gemini-3.5-flash-low(hook)
- opencode: Claude Sonnet 5.5(画面・設定: github-copilot/claude-sonnet-5.5)
- opencode-v2: Mistral Large 4 Ollama Cloud(画面下端・プロバイダ名を含む・設定: ollama-cloud/mistral-large-4)
- command-code: 不明
```

モデルは、ツール自身が見せたものだけを読む(`src/lib/agent-health/launched-model.ts`)。推測では埋めない。

| 読む場所 | ツール | 括弧内 |
|---|---|---|
| 画面(本番の `model-info-extractor`。claude のバナー、codex の下端、antigravity のバー、command-code の `# models:` 行、opencode の `▣ Build · <モデル>` の行。opencode-v2 は `▣` の無い `Build · <モデル> · 721ms` の行) | 全ツール | `画面` |
| hook の payload の model(claude の `SessionStart`、antigravity の `modelName` など) | hook を使うツール | `hook` |
| opencode の入力欄の下の `Build · <モデル> <プロバイダ>`(ターン前で上の行が無いとき。ANSI を除くとモデルとプロバイダの境目が無いので、まとめて残す) | opencode・opencode-v2 | `画面下端・プロバイダ名を含む` |

上の順に、最初に読めたものを使う。どれも読めなければ `不明`。`設定:` は起動前に複製した `model.json` の `recent[0]`(`<providerID>/<modelID>`)で、
読めた値と見比べるために並べる(代わりには使わない)。version だけを読むツールは起動しないので出さない。

## レポートの形

子 Issue #2879 の AI はこれだけを読む。フィールドの追加はよいが、名前の変更・削除は #2879 と #2880 を壊す。
Expand All @@ -178,6 +200,11 @@ interface AgentHealthReport {
skipKind?: 'no-definition' | 'not-shown' | 'signed-out' | 'unsupported' | 'timeout' | 'prerequisite-failed'; // skip には必ず入る
framePaths?: string[]; // 画面全体を保存したファイル(下の「画面の保存」)
}>;
launchedModel?: { // 起動したモデル(Issue #3438)。起動しなかったツールと #3438 より前のレポートには無い(読む側は「不明」)
model: string | null; // 読めなければ null(不明)
source?: 'screen' | 'screen-footer' | 'hook'; // model を読んだ場所
seeded?: string; // 複製した model.json の recent[0](opencode / opencode-v2)
};
}>;
safety: {
globalConfigRestored: Array<{
Expand Down
14 changes: 11 additions & 3 deletions scripts/agent-health/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
} from '@/lib/agent-health/types';
import { HookListener } from './hook-listener';
import { locateServerLog, ServerLogWatch } from './production-log';
import { probeLimitedTool, probeTool, readVersion } from './probe-tool';
import { probeLimitedTool, probeTool, readVersion, type ProbeOutcome } from './probe-tool';
import { AgentHealthTmux } from './tmux-driver';
import { LIMITED_TOOL_SPECS, TOOL_PROBE_SPECS } from './tool-table';

Expand Down Expand Up @@ -319,7 +319,7 @@ async function run(options: AgentHealthOptions, startedAt: Date): Promise<AgentH
readPickerSettings(readTextIfPresent(pickerSettings.path), pickerSettings.format, pickerSettings.keys);
const settingsBefore = readSettings();

let outcome: { version: string | null; checks: AgentHealthCheck[] };
let outcome: ProbeOutcome;
try {
outcome = await probeTool({
spec,
Expand Down Expand Up @@ -373,7 +373,15 @@ async function run(options: AgentHealthOptions, startedAt: Date): Promise<AgentH
];
}

results.push(buildToolResult({ tool, version: outcome.version, previousVersion, checks: outcome.checks }));
results.push(
buildToolResult({
tool,
version: outcome.version,
previousVersion,
checks: outcome.checks,
launchedModel: outcome.launchedModel,
})
);
}

// Issue #3313: the tools the probe does not launch are rows too — on the
Expand Down
34 changes: 33 additions & 1 deletion scripts/agent-health/probe-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ import {
expectedHookEvents,
} from '@/lib/agent-health/hook-correlation';
import { skipCheck } from '@/lib/agent-health/coverage';
import {
laterScreenModel,
launchedModelLabel,
readHookModel,
readScreenModel,
readSeededModel,
resolveLaunchedModel,
type ScreenModelReading,
} from '@/lib/agent-health/launched-model';
import { firstVersionLine, paneEvidence } from '@/lib/agent-health/report';
import { archiveCheckFrames, type FrameArchive, type JudgedFrame } from '@/lib/agent-health/frame-archive';
import { judgeServerEvents, judgeTurnScreen, turnEndedUnauthorized } from '@/lib/agent-health/model-auth';
Expand All @@ -43,6 +52,7 @@ import {
probeInstanceId,
type AgentHealthCheck,
type AgentHealthCheckId,
type AgentHealthLaunchedModel,
} from '@/lib/agent-health/types';
import type { AgentEventSource } from '@/lib/hooks/sources/types';
import type { HookListener } from './hook-listener';
Expand Down Expand Up @@ -99,6 +109,8 @@ export interface ProbeContext {
export interface ProbeOutcome {
version: string | null;
checks: AgentHealthCheck[];
/** Issue #3438: set once the tool was launched; left out when it never was. */
launchedModel?: AgentHealthLaunchedModel;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
Expand Down Expand Up @@ -162,6 +174,14 @@ export function seedStateFiles(seeds: Array<{ from: string; to: string }>): stri
return copied;
}

function readTextOrNull(file: string): string | null {
try {
return fs.readFileSync(file, 'utf8');
} catch {
return null;
}
}

/** Everything one session needs; one instance per tool. */
class ToolSession {
readonly name: string;
Expand All @@ -173,6 +193,8 @@ class ToolSession {
runningTurnUnauthorized = false;
/** Any turn did. */
anyTurnUnauthorized = false;
/** The model the frames looked at so far show (Issue #3438). */
screenModel: ScreenModelReading | null = null;

constructor(
private readonly ctx: ProbeContext,
Expand Down Expand Up @@ -221,6 +243,7 @@ class ToolSession {
async look(): Promise<{ frame: string; clean: string; verdict: ScreenVerdict }> {
const frame = await this.ctx.tmux.capture(this.name, this.spec.captureLines);
this.lastFrame = frame;
this.screenModel = laterScreenModel(this.screenModel, readScreenModel(this.spec.cliToolId, frame));
const result = detectSessionStatus(frame, this.spec.cliToolId);
return {
frame,
Expand Down Expand Up @@ -639,8 +662,10 @@ export async function probeTool(ctx: ProbeContext): Promise<ProbeOutcome> {
}
}
const command = buildProbeLaunchCommand(spec, rendered, workDir);
let seededModel: string | null = null;
for (const seeded of seedStateFiles(spec.seedFiles?.(workDir) ?? [])) {
ctx.log(`${spec.tool}: seeded ${seeded}`);
if (path.basename(seeded) === 'model.json') seededModel = readSeededModel(readTextOrNull(seeded));
}
ctx.log(`${spec.tool}: launching — ${command}`);

Expand Down Expand Up @@ -732,6 +757,13 @@ export async function probeTool(ctx: ProbeContext): Promise<ProbeOutcome> {
}
}

const launchedModel = resolveLaunchedModel({
screen: session.screenModel,
hook: readHookModel(spec.cliToolId, ctx.listener.forTool(spec.tool)),
seeded: seededModel,
});
ctx.log(`${spec.tool}: model ${launchedModelLabel(launchedModel)}`);

if (wantHooks) {
const verdict = evaluateHookCorrelation(ctx.listener.forTool(spec.tool), {
tool: spec.tool,
Expand All @@ -742,7 +774,7 @@ export async function probeTool(ctx: ProbeContext): Promise<ProbeOutcome> {
session.record({ checkId: 'hook-correlation', ...verdict });
}

return { version, checks: [...checks, ...session.checks.values()] };
return { version, checks: [...checks, ...session.checks.values()], launchedModel };
}

/**
Expand Down
8 changes: 8 additions & 0 deletions src/lib/agent-health/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type AgentHealthSkipKind,
type AgentHealthToolResult,
} from './types';
import { launchedModelLabel } from './launched-model';

/** A `skip` check. Every place that skips goes through this, so the kind is never left out. */
export function skipCheck(
Expand Down Expand Up @@ -152,5 +153,12 @@ export function summarizeCoverage(
}
}
if (reasons.length > 0) lines.push('', '未実施の理由:', ...reasons);

// Issue #3438: a pass keeps no evidence, so this is where the model a tool
// ran on is told. Version-only rows are never launched.
const models = coverage.rows
.filter((row) => row.coverage === 'probed' && results.some((entry) => entry.tool === row.tool))
.map((row) => `- ${row.tool}: ${launchedModelLabel(results.find((entry) => entry.tool === row.tool)?.launchedModel)}`);
if (models.length > 0) lines.push('', '起動したモデル:', ...models);
return lines;
}
163 changes: 163 additions & 0 deletions src/lib/agent-health/launched-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Which model each probed tool was launched on (Issue #3438).
*
* A `pass` check keeps no evidence, so before this the report could not say
* which model a passing tool ran on — not even whether opencode-v2 picked up
* the user's `model.json` (#3428). The value is read only where the tool
* itself shows it, never guessed:
*
* - the screen, through the production reader
* (`@/lib/detection/model-info-extractor`): claude's banner, codex's footer,
* antigravity's bar, command-code's `# models:` row, opencode's `▣ Build ·
* <model>` step row. opencode2 draws the step row without `▣`
* (`Build · <model> · 721ms`), read here with the same shape.
* - opencode's composer bar (`┃ Build · <model> <provider>`), the last resort:
* after `stripAnsi` the model and the provider are one string (see
* `OPENCODE_STEP_MODEL_PATTERN`'s comment), so it is kept whole and marked
* `screen-footer`.
* - the hooks the probe received (`NormalizedAgentEvent.model`: claude's
* `SessionStart`, antigravity's `modelName`, …).
*
* What none of them shows is `null` — read as 不明. `seeded` is the setting
* the probe copied in (`model.json`'s `recent[0]`), kept beside the read value
* so the two can be compared, never used in its place.
*/

import { stripAnsi } from '@/lib/detection/ansi';
import { extractModelInfo } from '@/lib/detection/model-info-extractor';
import type { CLIToolType } from '@/lib/cli-tools/types';
import { isAgentEventType } from '@/lib/hooks/agent-event-types';
import { getAgentEventSource } from '@/lib/hooks/sources';
import type { AgentHealthLaunchedModel, AgentHealthLaunchedModelSource } from './types';

/** What a report shows for a tool whose model could not be read, or an old report without the field. */
export const UNKNOWN_LAUNCHED_MODEL = '不明';

const SOURCE_LABELS: Record<AgentHealthLaunchedModelSource, string> = {
screen: '画面',
'screen-footer': '画面下端・プロバイダ名を含む',
hook: 'hook',
};

const MAX_MODEL_CHARS = 64;

/**
* opencode2's step row (` Build · Mistral Large 4 · 721ms`): v1's
* `▣ Build · <model> · <duration>` without the `▣`. The duration is required,
* so a line that merely has two dots does not read as one.
*/
const OPENCODE_STEP_ROW_PATTERN = /^\s*(?:▣\s+)?[A-Za-z][A-Za-z0-9-]*\s+·\s+([^·]+?)\s+·\s+[\d.]+\s?m?s\s*$/;

/** opencode's composer bar: `┃ Build · <model> <provider>[ · <variant>]`. */
const OPENCODE_COMPOSER_BAR_PATTERN = /^\s*┃\s+[A-Za-z][A-Za-z0-9-]*\s+·\s+([^·]+?)(?:\s+·\s+\S+)?\s*$/;

export interface ScreenModelReading {
model: string;
source: 'screen' | 'screen-footer';
}

function plausible(value: string): string | null {
const trimmed = value.trim();
return trimmed !== '' && trimmed.length <= MAX_MODEL_CHARS ? trimmed : null;
}

function scanFromEnd(frame: string, pattern: RegExp): string | null {
const lines = frame.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const match = pattern.exec(stripAnsi(lines[i]));
const model = match ? plausible(match[1]) : null;
if (model) return model;
}
return null;
}

/** The model one frame shows, or null when it shows none. */
export function readScreenModel(cliToolId: CLIToolType, frame: string): ScreenModelReading | null {
if (!frame) return null;
const opencode = cliToolId === 'opencode' || cliToolId === 'opencode-v2';
const read = extractModelInfo(opencode ? 'opencode' : cliToolId, frame).model;
if (read) return { model: read, source: 'screen' };
if (!opencode) return null;
const step = scanFromEnd(frame, OPENCODE_STEP_ROW_PATTERN);
if (step) return { model: step, source: 'screen' };
const bar = scanFromEnd(frame, OPENCODE_COMPOSER_BAR_PATTERN);
return bar ? { model: bar, source: 'screen-footer' } : null;
}

/**
* The reading to keep after a new frame: a step row or banner (`screen`) is
* never replaced by the composer bar; otherwise the newer frame wins.
*/
export function laterScreenModel(
previous: ScreenModelReading | null,
next: ScreenModelReading | null
): ScreenModelReading | null {
if (!next) return previous;
if (previous?.source === 'screen' && next.source === 'screen-footer') return previous;
return next;
}

/** The first model a hook payload names, in arrival order (claude's `SessionStart` comes first). */
export function readHookModel(
cliToolId: CLIToolType,
deliveries: ReadonlyArray<{ kind: string; event: string | null; body: Record<string, unknown> | null }>
): string | null {
for (const delivery of deliveries) {
if (delivery.kind !== 'agent-event' || delivery.body === null) continue;
try {
const normalized = getAgentEventSource(cliToolId).normalizeEvent({
payload: delivery.body,
event: isAgentEventType(delivery.event) ? delivery.event : null,
});
const model = normalized?.model ? plausible(normalized.model) : null;
if (model) return model;
} catch {
// A payload the source cannot read names no model.
}
}
return null;
}

/** `<providerID>/<modelID>` of `model.json`'s `recent[0]`, or null. */
export function readSeededModel(text: string | null): string | null {
if (text === null) return null;
try {
const parsed = JSON.parse(text) as { recent?: unknown };
if (!Array.isArray(parsed?.recent)) return null;
const first = parsed.recent[0] as { providerID?: unknown; modelID?: unknown } | undefined;
if (typeof first?.modelID !== 'string' || first.modelID === '') return null;
return typeof first.providerID === 'string' && first.providerID !== ''
? `${first.providerID}/${first.modelID}`
: first.modelID;
} catch {
return null;
}
}

/** The screen's step row or banner, then the hooks, then the composer bar; null when none. */
export function resolveLaunchedModel(input: {
screen: ScreenModelReading | null;
hook: string | null;
seeded?: string | null;
}): AgentHealthLaunchedModel {
const seeded = input.seeded ? { seeded: input.seeded } : {};
if (input.screen?.source === 'screen') return { model: input.screen.model, source: 'screen', ...seeded };
if (input.hook) return { model: input.hook, source: 'hook', ...seeded };
if (input.screen) return { model: input.screen.model, source: input.screen.source, ...seeded };
return { model: null, ...seeded };
}

/**
* One line for the summary: `Haiku 4.5(画面)`, `不明`, `不明(設定: github-copilot/…)`.
* A report written before #3438 has no `launchedModel` and reads 不明.
*/
export function launchedModelLabel(launched: AgentHealthLaunchedModel | null | undefined): string {
const model = typeof launched?.model === 'string' && launched.model !== '' ? launched.model : null;
const source = launched?.source && SOURCE_LABELS[launched.source] ? SOURCE_LABELS[launched.source] : null;
const notes = [
...(model && source ? [source] : []),
...(typeof launched?.seeded === 'string' && launched.seeded !== '' ? [`設定: ${launched.seeded}`] : []),
];
const head = model ?? UNKNOWN_LAUNCHED_MODEL;
return notes.length > 0 ? `${head}(${notes.join('・')})` : head;
}
4 changes: 4 additions & 0 deletions src/lib/agent-health/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
EVIDENCE_PANE_LINES,
MAX_EVIDENCE_CHARS,
type AgentHealthCheck,
type AgentHealthLaunchedModel,
type AgentHealthReport,
type AgentHealthState,
type AgentHealthReportTool,
Expand Down Expand Up @@ -68,6 +69,8 @@ export function buildToolResult(input: {
version: string | null;
previousVersion: string | null;
checks: readonly AgentHealthCheck[];
/** Issue #3438: left out when the tool was not launched. */
launchedModel?: AgentHealthLaunchedModel;
}): AgentHealthToolResult {
return {
tool: input.tool,
Expand All @@ -78,6 +81,7 @@ export function buildToolResult(input: {
...check,
...(check.evidence !== undefined ? { evidence: truncateEvidence(check.evidence) } : {}),
})),
...(input.launchedModel ? { launchedModel: input.launchedModel } : {}),
};
}

Expand Down
Loading
Loading