agentprof 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentprof/__init__.py +7 -0
- agentprof/adapters/__init__.py +15 -0
- agentprof/adapters/base.py +79 -0
- agentprof/adapters/claude_code/__init__.py +3 -0
- agentprof/adapters/claude_code/adapter.py +133 -0
- agentprof/adapters/claude_code/discovery.py +79 -0
- agentprof/adapters/claude_code/prompts.py +44 -0
- agentprof/adapters/claude_code/tools.py +102 -0
- agentprof/adapters/claude_code/transcript.py +264 -0
- agentprof/adapters/claude_code/tree.py +492 -0
- agentprof/adapters/codex/__init__.py +3 -0
- agentprof/adapters/codex/adapter.py +164 -0
- agentprof/adapters/codex/discovery.py +117 -0
- agentprof/adapters/codex/prompts.py +16 -0
- agentprof/adapters/codex/rollout.py +328 -0
- agentprof/adapters/codex/tools.py +97 -0
- agentprof/adapters/codex/tree.py +311 -0
- agentprof/adapters/copilot_vscode/__init__.py +3 -0
- agentprof/adapters/copilot_vscode/adapter.py +166 -0
- agentprof/adapters/copilot_vscode/debuglog.py +136 -0
- agentprof/adapters/copilot_vscode/discovery.py +153 -0
- agentprof/adapters/copilot_vscode/session.py +281 -0
- agentprof/adapters/copilot_vscode/tools.py +127 -0
- agentprof/adapters/copilot_vscode/transcript.py +176 -0
- agentprof/adapters/copilot_vscode/tree.py +325 -0
- agentprof/adapters/execution.py +64 -0
- agentprof/adapters/timestamps.py +13 -0
- agentprof/adapters/turns.py +24 -0
- agentprof/analysis/__init__.py +3 -0
- agentprof/analysis/agent_summary.py +161 -0
- agentprof/analysis/call_context.py +101 -0
- agentprof/analysis/evidence_findings.py +151 -0
- agentprof/analysis/execution.py +39 -0
- agentprof/analysis/heuristics.py +306 -0
- agentprof/analysis/pipeline.py +31 -0
- agentprof/analysis/rollup.py +196 -0
- agentprof/cli.py +141 -0
- agentprof/model.py +341 -0
- agentprof/pricing.json +23 -0
- agentprof/pricing.py +120 -0
- agentprof/registry.py +304 -0
- agentprof/server/__init__.py +3 -0
- agentprof/server/app.py +399 -0
- agentprof/server/openapi.py +31 -0
- agentprof/server/pricing_schema.py +40 -0
- agentprof/server/schemas.py +579 -0
- agentprof/server/static/assets/index-C4HEqUVf.css +1 -0
- agentprof/server/static/assets/index-ZsPn1H3d.js +58 -0
- agentprof/server/static/index.html +14 -0
- agentprof-0.1.0.dist-info/METADATA +177 -0
- agentprof-0.1.0.dist-info/RECORD +54 -0
- agentprof-0.1.0.dist-info/WHEEL +4 -0
- agentprof-0.1.0.dist-info/entry_points.txt +8 -0
- agentprof-0.1.0.dist-info/licenses/LICENSE +21 -0
agentprof/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Agent adapters: turn agent-specific session files into the neutral model."""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
|
|
7
|
+
from agentprof.adapters.base import AdapterConfig, AgentAdapter
|
|
8
|
+
|
|
9
|
+
ENTRY_POINT_GROUP = "agentprof.adapters"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_adapters(config: AdapterConfig) -> list[AgentAdapter]:
|
|
13
|
+
"""Instantiate every adapter registered under the `agentprof.adapters` entry point group."""
|
|
14
|
+
registered = sorted(entry_points(group=ENTRY_POINT_GROUP), key=lambda entry_point: entry_point.name)
|
|
15
|
+
return [entry_point.load()(config) for entry_point in registered]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""The adapter protocol and the lightweight types the registry works with."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from agentprof.model import CostMetric, Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def latest_mtime(paths: Iterable[Path]) -> float:
|
|
14
|
+
"""The newest modification time among `paths`; paths that vanished meanwhile are ignored."""
|
|
15
|
+
times: list[float] = []
|
|
16
|
+
for path in paths:
|
|
17
|
+
try:
|
|
18
|
+
times.append(path.stat().st_mtime)
|
|
19
|
+
except OSError:
|
|
20
|
+
continue
|
|
21
|
+
return max(times, default=0.0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AdapterConfig:
|
|
26
|
+
"""User configuration shared by all adapters.
|
|
27
|
+
|
|
28
|
+
`roots` maps an adapter name to an overriding data root; `pricing_file` replaces the bundled price table.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
roots: dict[str, Path] = field(default_factory=dict)
|
|
32
|
+
pricing_file: Path | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class SessionRef:
|
|
37
|
+
"""A cheap handle to one session file."""
|
|
38
|
+
|
|
39
|
+
agent: str
|
|
40
|
+
native_id: str
|
|
41
|
+
path: Path
|
|
42
|
+
mtime: float
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def id(self) -> str:
|
|
46
|
+
return f"{self.agent}:{self.native_id}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SessionSummary:
|
|
51
|
+
"""One row of the session list: only what an adapter can read without building the tree."""
|
|
52
|
+
|
|
53
|
+
id: str
|
|
54
|
+
agent: str
|
|
55
|
+
title: str
|
|
56
|
+
workspace: str | None
|
|
57
|
+
start_ms: float | None
|
|
58
|
+
end_ms: float | None
|
|
59
|
+
file_size: int
|
|
60
|
+
last_activity_ms: float | None = None
|
|
61
|
+
cost_total: CostMetric = field(default_factory=CostMetric.not_available)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AgentAdapter(Protocol):
|
|
65
|
+
"""Turns one agent's session files into the neutral model.
|
|
66
|
+
|
|
67
|
+
Implementations are constructed with an `AdapterConfig` and registered under the entry point group
|
|
68
|
+
`agentprof.adapters`. `summarize` and `analyze` raise on sessions they cannot read.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
|
|
73
|
+
def discover(self) -> Iterable[SessionRef]: ...
|
|
74
|
+
|
|
75
|
+
def open_path(self, path: Path) -> SessionRef | None: ...
|
|
76
|
+
|
|
77
|
+
def summarize(self, ref: SessionRef) -> SessionSummary: ...
|
|
78
|
+
|
|
79
|
+
def analyze(self, ref: SessionRef) -> Session: ...
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""The Claude Code adapter."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agentprof.adapters.base import AdapterConfig, SessionRef, SessionSummary, latest_mtime
|
|
12
|
+
from agentprof.adapters.claude_code.discovery import (
|
|
13
|
+
default_projects_root,
|
|
14
|
+
load_subagents,
|
|
15
|
+
session_files,
|
|
16
|
+
subagent_dir,
|
|
17
|
+
)
|
|
18
|
+
from agentprof.adapters.claude_code.prompts import NO_PROMPT, prompt_topic
|
|
19
|
+
from agentprof.adapters.claude_code.tools import is_known_tool_id
|
|
20
|
+
from agentprof.adapters.claude_code.transcript import Transcript, load_transcript
|
|
21
|
+
from agentprof.adapters.claude_code.tree import build_root, message_cost
|
|
22
|
+
from agentprof.model import Diagnostics, Session, sum_costs
|
|
23
|
+
from agentprof.pricing import PriceTable
|
|
24
|
+
|
|
25
|
+
_TITLE_LENGTH = 80
|
|
26
|
+
_SNIFF_LINES = 20
|
|
27
|
+
_SUBAGENT_DIR = "subagents"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _title(main: Transcript, fallback: str) -> str:
|
|
31
|
+
if main.title:
|
|
32
|
+
return main.title
|
|
33
|
+
for prompt in main.prompts:
|
|
34
|
+
topic = prompt_topic(prompt.text)
|
|
35
|
+
if topic != NO_PROMPT:
|
|
36
|
+
return topic[:_TITLE_LENGTH]
|
|
37
|
+
return fallback
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _looks_like_session(path: Path) -> bool:
|
|
41
|
+
"""Whether one of the first lines is a Claude Code entry carrying a `sessionId`."""
|
|
42
|
+
try:
|
|
43
|
+
with path.open(encoding="utf-8") as lines:
|
|
44
|
+
for _, line in zip(range(_SNIFF_LINES), lines, strict=False):
|
|
45
|
+
try:
|
|
46
|
+
entry = json.loads(line)
|
|
47
|
+
except ValueError:
|
|
48
|
+
continue
|
|
49
|
+
if isinstance(entry, dict) and isinstance(entry.get("sessionId"), str):
|
|
50
|
+
return True
|
|
51
|
+
except (OSError, UnicodeDecodeError):
|
|
52
|
+
return False
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ClaudeCodeAdapter:
|
|
57
|
+
"""Reads Claude Code sessions from `~/.claude/projects`."""
|
|
58
|
+
|
|
59
|
+
name = "claude-code"
|
|
60
|
+
|
|
61
|
+
def __init__(self, config: AdapterConfig) -> None:
|
|
62
|
+
override = config.roots.get(self.name)
|
|
63
|
+
self._root = override if override is not None else default_projects_root(os.environ, Path.home())
|
|
64
|
+
self._prices = PriceTable.load(config.pricing_file)
|
|
65
|
+
|
|
66
|
+
def _ref(self, path: Path) -> SessionRef:
|
|
67
|
+
files = [path, *subagent_dir(path).glob("agent-*")]
|
|
68
|
+
return SessionRef(agent=self.name, native_id=path.stem, path=path, mtime=latest_mtime(files))
|
|
69
|
+
|
|
70
|
+
def discover(self) -> Iterator[SessionRef]:
|
|
71
|
+
for path in session_files(self._root):
|
|
72
|
+
yield self._ref(path)
|
|
73
|
+
|
|
74
|
+
def open_path(self, path: Path) -> SessionRef | None:
|
|
75
|
+
if path.suffix != ".jsonl" or path.parent.name == _SUBAGENT_DIR or not path.is_file():
|
|
76
|
+
return None
|
|
77
|
+
return self._ref(path) if _looks_like_session(path) else None
|
|
78
|
+
|
|
79
|
+
def summarize(self, ref: SessionRef) -> SessionSummary:
|
|
80
|
+
main = load_transcript(ref.path)
|
|
81
|
+
transcripts = [main, *(subagent.transcript for subagent in load_subagents(ref.path))]
|
|
82
|
+
return SessionSummary(
|
|
83
|
+
id=ref.id,
|
|
84
|
+
agent=self.name,
|
|
85
|
+
title=_title(main, ref.native_id),
|
|
86
|
+
workspace=main.cwd,
|
|
87
|
+
start_ms=main.first_timestamp_ms if main.first_timestamp_ms is not None else ref.mtime * 1000,
|
|
88
|
+
end_ms=ref.mtime * 1000,
|
|
89
|
+
file_size=ref.path.stat().st_size,
|
|
90
|
+
last_activity_ms=max(
|
|
91
|
+
(timestamp for transcript in transcripts if (timestamp := transcript.last_message_ms) is not None),
|
|
92
|
+
default=None,
|
|
93
|
+
),
|
|
94
|
+
cost_total=sum_costs(
|
|
95
|
+
[message_cost(message, self._prices) for transcript in transcripts for message in transcript.messages]
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def analyze(self, ref: SessionRef) -> Session:
|
|
100
|
+
main = load_transcript(ref.path)
|
|
101
|
+
subagents = load_subagents(ref.path)
|
|
102
|
+
transcripts = [main, *(subagent.transcript for subagent in subagents)]
|
|
103
|
+
diagnostics = Diagnostics(malformed_lines=sum(transcript.malformed_lines for transcript in transcripts))
|
|
104
|
+
diagnostics.malformed_line_details = [
|
|
105
|
+
detail for transcript in transcripts for detail in transcript.malformed_line_details
|
|
106
|
+
][:20]
|
|
107
|
+
diagnostics.unknown_tool_ids = dict(
|
|
108
|
+
Counter(
|
|
109
|
+
tool_use.name
|
|
110
|
+
for transcript in transcripts
|
|
111
|
+
for tool_use in transcript.tool_uses
|
|
112
|
+
if not is_known_tool_id(tool_use.name)
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
unpriced = sorted(
|
|
116
|
+
{
|
|
117
|
+
message.model
|
|
118
|
+
for transcript in transcripts
|
|
119
|
+
for message in transcript.messages
|
|
120
|
+
if message.model and self._prices.price_for(message.model) is None
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
diagnostics.warnings.extend(f"No price for model {model!r}: its cost is unavailable." for model in unpriced)
|
|
124
|
+
title = _title(main, ref.native_id)
|
|
125
|
+
return Session(
|
|
126
|
+
id=ref.id,
|
|
127
|
+
agent=self.name,
|
|
128
|
+
title=title,
|
|
129
|
+
workspace=main.cwd,
|
|
130
|
+
root=build_root(main, subagents, self._prices, title),
|
|
131
|
+
sources=["transcript", *(["subagents"] if subagents else [])],
|
|
132
|
+
diagnostics=diagnostics,
|
|
133
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Locate Claude Code session files and their subagent transcripts."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from agentprof.adapters.claude_code.transcript import Transcript, load_transcript
|
|
11
|
+
|
|
12
|
+
_AGENT_PREFIX = "agent-"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SubagentMeta:
|
|
17
|
+
"""Contents of `agent-<id>.meta.json`; `tool_use_id` names the `Agent` call that spawned the subagent."""
|
|
18
|
+
|
|
19
|
+
agent_id: str
|
|
20
|
+
tool_use_id: str | None
|
|
21
|
+
agent_type: str | None
|
|
22
|
+
description: str | None
|
|
23
|
+
model: str | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Subagent:
|
|
28
|
+
meta: SubagentMeta
|
|
29
|
+
transcript: Transcript
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def default_projects_root(env: Mapping[str, str], home: Path) -> Path:
|
|
33
|
+
"""`$CLAUDE_CONFIG_DIR/projects` if set, else `~/.claude/projects`."""
|
|
34
|
+
config_dir = env.get("CLAUDE_CONFIG_DIR")
|
|
35
|
+
return (Path(config_dir) if config_dir else home / ".claude") / "projects"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def session_files(root: Path) -> list[Path]:
|
|
39
|
+
"""Main transcripts: `<root>/<project-dir>/<session-id>.jsonl` (subagent files sit deeper)."""
|
|
40
|
+
return sorted(root.glob("*/*.jsonl"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def subagent_dir(session_file: Path) -> Path:
|
|
44
|
+
"""`<project-dir>/<session-id>/subagents/`, next to the main transcript."""
|
|
45
|
+
return session_file.parent / session_file.stem / "subagents"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_meta(agent_id: str, meta_file: Path) -> SubagentMeta:
|
|
49
|
+
"""Read a subagent's meta file; missing or invalid files yield a meta without spawning call."""
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
data = {}
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
data = {}
|
|
56
|
+
|
|
57
|
+
def text(key: str) -> str | None:
|
|
58
|
+
value = data.get(key)
|
|
59
|
+
return value if isinstance(value, str) and value else None
|
|
60
|
+
|
|
61
|
+
return SubagentMeta(
|
|
62
|
+
agent_id=agent_id,
|
|
63
|
+
tool_use_id=text("toolUseId"),
|
|
64
|
+
agent_type=text("agentType"),
|
|
65
|
+
description=text("description"),
|
|
66
|
+
model=text("model"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_subagents(session_file: Path) -> list[Subagent]:
|
|
71
|
+
"""Parse every subagent transcript of a session, flat regardless of nesting depth."""
|
|
72
|
+
directory = subagent_dir(session_file)
|
|
73
|
+
subagents: list[Subagent] = []
|
|
74
|
+
for path in sorted(directory.glob(f"{_AGENT_PREFIX}*.jsonl")):
|
|
75
|
+
agent_id = path.stem.removeprefix(_AGENT_PREFIX)
|
|
76
|
+
subagents.append(
|
|
77
|
+
Subagent(meta=read_meta(agent_id, path.with_suffix(".meta.json")), transcript=load_transcript(path))
|
|
78
|
+
)
|
|
79
|
+
return subagents
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Derive a short, human-readable topic from a Claude Code turn prompt."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
NO_PROMPT = "(no prompt)"
|
|
8
|
+
_TOPIC_LENGTH = 200
|
|
9
|
+
_BLOCK_TAGS = ("ide_opened_file", "ide_selection", "system-reminder", "local-command-caveat")
|
|
10
|
+
_BLOCK_RE = re.compile("|".join(rf"<{tag}>.*?</{tag}>" for tag in _BLOCK_TAGS), re.DOTALL)
|
|
11
|
+
_COMMAND_RE = re.compile(r"<command-name>(.*?)</command-name>(?:<command-args>(.*?)</command-args>)?", re.DOTALL)
|
|
12
|
+
_LOCAL_COMMAND_STDOUT_RE = re.compile(r"<local-command-stdout>.*?</local-command-stdout>", re.DOTALL)
|
|
13
|
+
_TASK_NOTIFICATION_RE = re.compile(r"<task-notification>(.*?)</task-notification>", re.DOTALL)
|
|
14
|
+
_SUMMARY_RE = re.compile(r"<summary>(.*?)</summary>", re.DOTALL)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def first_line(text: str, limit: int) -> str:
|
|
18
|
+
"""The first non-blank line of `text`, stripped and cut to `limit` characters."""
|
|
19
|
+
return next((line.strip()[:limit] for line in text.splitlines() if line.strip()), "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prompt_topic(text: str) -> str:
|
|
23
|
+
"""A short topic for a turn prompt, stripping IDE/harness context and special markup."""
|
|
24
|
+
stripped = _BLOCK_RE.sub("", text)
|
|
25
|
+
|
|
26
|
+
command_match = _COMMAND_RE.search(stripped)
|
|
27
|
+
if command_match is not None:
|
|
28
|
+
name = command_match.group(1).strip()
|
|
29
|
+
args = (command_match.group(2) or "").strip()
|
|
30
|
+
return f"{name} {args}" if args else name
|
|
31
|
+
|
|
32
|
+
without_stdout = _LOCAL_COMMAND_STDOUT_RE.sub("", stripped)
|
|
33
|
+
if without_stdout != stripped and not without_stdout.strip():
|
|
34
|
+
return "(command output)"
|
|
35
|
+
stripped = without_stdout
|
|
36
|
+
|
|
37
|
+
notification_match = _TASK_NOTIFICATION_RE.search(stripped)
|
|
38
|
+
if notification_match is not None:
|
|
39
|
+
summary_match = _SUMMARY_RE.search(notification_match.group(1))
|
|
40
|
+
if summary_match is not None:
|
|
41
|
+
return f"Task notification: {summary_match.group(1).strip()}"
|
|
42
|
+
return "Task notification"
|
|
43
|
+
|
|
44
|
+
return first_line(stripped, _TOPIC_LENGTH) or NO_PROMPT
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 epicodic
|
|
3
|
+
"""Map Claude Code tool names to neutral tool categories and normalised arguments."""
|
|
4
|
+
|
|
5
|
+
from agentprof.model import ToolCategory, ToolInfo
|
|
6
|
+
|
|
7
|
+
_CATEGORIES: dict[str, ToolCategory] = {
|
|
8
|
+
"Read": ToolCategory.READ,
|
|
9
|
+
"Edit": ToolCategory.EDIT,
|
|
10
|
+
"Write": ToolCategory.EDIT,
|
|
11
|
+
"NotebookEdit": ToolCategory.EDIT,
|
|
12
|
+
"Grep": ToolCategory.SEARCH,
|
|
13
|
+
"Glob": ToolCategory.SEARCH,
|
|
14
|
+
"Bash": ToolCategory.SHELL,
|
|
15
|
+
"BashOutput": ToolCategory.SHELL_POLL,
|
|
16
|
+
"TaskOutput": ToolCategory.SHELL_POLL,
|
|
17
|
+
"Monitor": ToolCategory.SHELL_POLL,
|
|
18
|
+
"WebFetch": ToolCategory.WEB,
|
|
19
|
+
"WebSearch": ToolCategory.WEB,
|
|
20
|
+
"Agent": ToolCategory.SUBAGENT,
|
|
21
|
+
"Task": ToolCategory.SUBAGENT,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Built-in harness tools without a dedicated category; they map to `other` but are not reported as unknown.
|
|
25
|
+
_KNOWN_OTHER_TOOL_IDS = {
|
|
26
|
+
"AskUserQuestion",
|
|
27
|
+
"ToolSearch",
|
|
28
|
+
"Skill",
|
|
29
|
+
"SendMessage",
|
|
30
|
+
"ListAgents",
|
|
31
|
+
"TaskCreate",
|
|
32
|
+
"TaskUpdate",
|
|
33
|
+
"TaskList",
|
|
34
|
+
"TaskGet",
|
|
35
|
+
"TaskStop",
|
|
36
|
+
"KillShell",
|
|
37
|
+
"TodoWrite",
|
|
38
|
+
"EnterPlanMode",
|
|
39
|
+
"ExitPlanMode",
|
|
40
|
+
"EnterWorktree",
|
|
41
|
+
"ExitWorktree",
|
|
42
|
+
"ScheduleWakeup",
|
|
43
|
+
"CronCreate",
|
|
44
|
+
"CronDelete",
|
|
45
|
+
"CronList",
|
|
46
|
+
"PushNotification",
|
|
47
|
+
"RemoteTrigger",
|
|
48
|
+
"ReadNotifications",
|
|
49
|
+
"SendUserFile",
|
|
50
|
+
"SlashCommand",
|
|
51
|
+
"LSP",
|
|
52
|
+
"Artifact",
|
|
53
|
+
"ArtifactComments",
|
|
54
|
+
"ArtifactData",
|
|
55
|
+
"ReportFindings",
|
|
56
|
+
"SendFeedback",
|
|
57
|
+
}
|
|
58
|
+
_CATEGORIES_CASEFOLDED = {name.casefold(): category for name, category in _CATEGORIES.items()}
|
|
59
|
+
_KNOWN_OTHER_TOOL_IDS_CASEFOLDED = {name.casefold() for name in _KNOWN_OTHER_TOOL_IDS}
|
|
60
|
+
_MCP_PREFIX = "mcp__"
|
|
61
|
+
_FILE_CATEGORIES = (ToolCategory.READ, ToolCategory.EDIT)
|
|
62
|
+
_WHOLE_FILE_WRITERS = {"write"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def is_known_tool_id(name: str) -> bool:
|
|
66
|
+
"""Whether `name` is expected; unknown names are counted in the session diagnostics."""
|
|
67
|
+
normalized = name.casefold()
|
|
68
|
+
return (
|
|
69
|
+
normalized in _CATEGORIES_CASEFOLDED
|
|
70
|
+
or normalized in _KNOWN_OTHER_TOOL_IDS_CASEFOLDED
|
|
71
|
+
or normalized.startswith(_MCP_PREFIX)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _positive_int(value: object) -> int | None:
|
|
76
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _line_range(arguments: dict[str, object]) -> tuple[int, int] | None:
|
|
80
|
+
"""`Read` takes a 1-based start line `offset` and a line count `limit`; without both it may read the whole file."""
|
|
81
|
+
offset = _positive_int(arguments.get("offset"))
|
|
82
|
+
limit = _positive_int(arguments.get("limit"))
|
|
83
|
+
if limit is None:
|
|
84
|
+
return None
|
|
85
|
+
start = offset or 1
|
|
86
|
+
return (start, start + limit - 1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def tool_info(name: str, arguments: dict[str, object]) -> ToolInfo:
|
|
90
|
+
"""Build the neutral `ToolInfo` for one Claude Code tool call."""
|
|
91
|
+
category = _CATEGORIES_CASEFOLDED.get(name.casefold(), ToolCategory.OTHER)
|
|
92
|
+
path = arguments.get("file_path") or arguments.get("notebook_path")
|
|
93
|
+
command = arguments.get("command")
|
|
94
|
+
return ToolInfo(
|
|
95
|
+
native_id=name,
|
|
96
|
+
category=category,
|
|
97
|
+
path=str(path) if path and category in _FILE_CATEGORIES else None,
|
|
98
|
+
line_range=_line_range(arguments) if category is ToolCategory.READ else None,
|
|
99
|
+
command=str(command) if command and category is ToolCategory.SHELL else None,
|
|
100
|
+
writes_file=name.casefold() in _WHOLE_FILE_WRITERS,
|
|
101
|
+
arguments=arguments,
|
|
102
|
+
)
|