Files changed: - .gitattributes - CHANGES.md - VERSION - raw/CONTRACT.md - tools/README.md - tools/chemenu/commands/_util.py - tools/chemenu/commands/dist_cmd.py - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/eval_cmd.py - tools/chemenu/commands/git_publish.py - tools/chemenu/commands/index_build.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/log_append.py - tools/chemenu/commands/migrate_cmd.py - tools/chemenu/commands/provenance_cmd.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/commands/run_budget.py - tools/chemenu/commands/upstream_cmd.py - tools/chemenu/commands/version_cmd.py - tools/chemenu/commands/work_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/filelock.py - tools/chemenu/frontmatter_io.py - tools/chemenu/kb_scan.py - tools/chemenu/kb_state.py - tools/chemenu/lint_core.py - tools/chemenu/prerequisites.py - tools/chemenu/provenance.py - tools/chemenu/search/base.py - tools/chemenu/search/ripgrep.py - tools/chemenu/telemetry/writer.py - tools/chemenu/tests/test_dist_cmd.py - tools/chemenu/tests/test_portability.py - tools/chemenu/tests/test_search.py - tools/chemenu/tests/test_trace_ingest.py - tools/chemenu/type_resolver.py - tools/chemenu/upload.py - tools/chemenu/version.py - tools/run_wikitool.py - tools/trace_ingest.py
277 lines
10 KiB
Python
Executable File
277 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Turn a harness hook payload into a trace event.
|
|
|
|
Hooks call this, never `wikitool`. A hook fires on every tool call, and every
|
|
`wikitool` invocation is counted by the Iteration Budget Gate - wiring telemetry
|
|
through the CLI would let the act of observing a session end it. This entry
|
|
point imports the telemetry package as a library instead, which costs no budget
|
|
and needs no venv: `chemenu.telemetry` is stdlib-only on purpose.
|
|
|
|
Contract with the harness:
|
|
|
|
* stdin is the hook's JSON payload; a missing, empty or malformed payload is not
|
|
an error worth failing a tool call over.
|
|
* stdout stays empty. Claude Code and Copilot CLI both read a hook's stdout as a
|
|
decision document, so anything printed here could alter what the agent does.
|
|
`--dry-run` is the exception, and it is for humans and tests.
|
|
* the exit code is always 0. A hook that exits non-zero can block a tool call,
|
|
and an observer must never do that.
|
|
|
|
Usage from a hook:
|
|
|
|
tools/trace_ingest.py --source copilot-cli --event tool.pre
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from chemenu.telemetry import schema # noqa: E402
|
|
from chemenu.telemetry.writer import write_event # noqa: E402
|
|
|
|
# Harness event name -> our event name. The keys are what each vendor documents;
|
|
# the values are the vocabulary in schema.py. A harness that cannot report an
|
|
# event class simply has no row for it - Mistral Vibe has three.
|
|
EVENT_MAPS: dict[str, dict[str, str]] = {
|
|
"claude-code": {
|
|
"SessionStart": "session.start",
|
|
"SessionEnd": "session.end",
|
|
"UserPromptSubmit": "prompt.submitted",
|
|
"PreToolUse": "tool.pre",
|
|
"PostToolUse": "tool.post",
|
|
"PostToolUseFailure": "tool.error",
|
|
"SubagentStart": "subagent.start",
|
|
"SubagentStop": "subagent.stop",
|
|
"InstructionsLoaded": "instructions.loaded",
|
|
"PreCompact": "compaction",
|
|
"PostCompact": "compaction",
|
|
"Stop": "turn.end",
|
|
},
|
|
# Copilot CLI serves two payload dialects, chosen by how the event is
|
|
# spelled in the hook config: camelCase names give camelCase fields,
|
|
# PascalCase names give the VS Code / Claude snake_case shape. Both spellings
|
|
# are mapped so a trace stays readable whichever a config uses.
|
|
"copilot-cli": {
|
|
"sessionStart": "session.start",
|
|
"SessionStart": "session.start",
|
|
"sessionEnd": "session.end",
|
|
"SessionEnd": "session.end",
|
|
"userPromptSubmitted": "prompt.submitted",
|
|
"UserPromptSubmit": "prompt.submitted",
|
|
"preToolUse": "tool.pre",
|
|
"PreToolUse": "tool.pre",
|
|
"postToolUse": "tool.post",
|
|
"PostToolUse": "tool.post",
|
|
"postToolUseFailure": "tool.error",
|
|
"PostToolUseFailure": "tool.error",
|
|
"errorOccurred": "session.error",
|
|
"ErrorOccurred": "session.error",
|
|
"subagentStart": "subagent.start",
|
|
"subagentStop": "subagent.stop",
|
|
"SubagentStop": "subagent.stop",
|
|
"preCompact": "compaction",
|
|
"PreCompact": "compaction",
|
|
"agentStop": "turn.end",
|
|
"Stop": "turn.end",
|
|
},
|
|
"mistral-vibe": {
|
|
"pre_tool": "tool.pre",
|
|
"post_tool": "tool.post",
|
|
"post_agent": "turn.end",
|
|
},
|
|
}
|
|
|
|
# Normalised field -> the spellings the three harnesses use, in priority order.
|
|
FIELD_ALIASES: dict[str, tuple[str, ...]] = {
|
|
"tool_name": ("tool_name", "toolName", "tool"),
|
|
"tool_call_id": ("tool_call_id", "tool_use_id", "toolCallId", "toolUseId"),
|
|
"tool_input": ("tool_input", "toolArgs", "tool_args", "toolInput"),
|
|
"tool_output": ("tool_output_text", "tool_output", "tool_response", "toolResult"),
|
|
"tool_status": ("tool_status", "status", "toolStatus"),
|
|
"duration_ms": ("duration_ms", "durationMs"),
|
|
"error": ("tool_error", "error", "errorMessage"),
|
|
"error_context": ("error_context", "errorContext"),
|
|
"prompt": ("prompt", "userPrompt", "promptText", "initialPrompt", "initial_prompt"),
|
|
"message": ("last_assistant_message", "assistantMessage", "response", "message"),
|
|
"cwd": ("cwd", "workingDirectory"),
|
|
"transcript_path": ("transcript_path", "transcriptPath"),
|
|
"file_path": ("file_path", "filePath"),
|
|
"agent_type": ("agent_type", "agentType", "subagent_type", "agentName", "agent_name"),
|
|
"parent_session_id": ("parent_session_id", "parentSessionId"),
|
|
"stop_reason": ("stop_reason", "stopReason"),
|
|
"reason": ("reason",),
|
|
"trigger": ("trigger",),
|
|
"source_kind": ("source",),
|
|
}
|
|
|
|
# Values that live one level down. Copilot's postToolUse wraps the text the
|
|
# model saw in a result object rather than handing it over flat.
|
|
NESTED_ALIASES: dict[str, tuple[tuple[str, str], ...]] = {
|
|
"tool_output": (
|
|
("toolResult", "textResultForLlm"),
|
|
("tool_result", "text_result_for_llm"),
|
|
),
|
|
}
|
|
|
|
SESSION_ALIASES = ("session_id", "sessionId", "session")
|
|
|
|
# Leftover payload keys worth keeping. Anything else is vendor bookkeeping that
|
|
# would only bloat the trace - a trace is scoring material, not an archive of
|
|
# the vendor's payload.
|
|
EXTRA_SCALAR_TYPES = (str, int, float, bool)
|
|
|
|
|
|
def _first(payload: dict, names: tuple[str, ...]):
|
|
for name in names:
|
|
if name in payload and payload[name] not in (None, ""):
|
|
return payload[name]
|
|
return None
|
|
|
|
|
|
def _as_text(value) -> str:
|
|
if isinstance(value, str):
|
|
return value
|
|
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
|
|
|
|
def _nested(payload: dict, paths: tuple[tuple[str, str], ...]):
|
|
for outer, inner in paths:
|
|
container = payload.get(outer)
|
|
if isinstance(container, dict) and container.get(inner):
|
|
return container[inner]
|
|
return None
|
|
|
|
|
|
def _error_text(value) -> str:
|
|
"""`postToolUseFailure` hands over a string, `errorOccurred` an object."""
|
|
if isinstance(value, dict):
|
|
return value.get("message") or _as_text(value)
|
|
return _as_text(value)
|
|
|
|
|
|
def resolve_event(source: str, payload: dict, explicit: str | None) -> str | None:
|
|
"""`--event` wins: not every harness names its event inside the payload."""
|
|
if explicit:
|
|
return explicit
|
|
raw = payload.get("hook_event_name") or payload.get("hookEventName")
|
|
if not raw:
|
|
return None
|
|
return EVENT_MAPS.get(source, {}).get(raw)
|
|
|
|
|
|
def normalise(source: str, event: str, payload: dict) -> dict:
|
|
attrs: dict = {"harness_event": payload.get("hook_event_name") or payload.get("hookEventName")}
|
|
|
|
for field, aliases in FIELD_ALIASES.items():
|
|
value = _first(payload, aliases)
|
|
if value is None:
|
|
continue
|
|
if field == "error":
|
|
attrs[field] = _error_text(value)
|
|
elif field in ("tool_input", "tool_output"):
|
|
attrs[field] = _as_text(value)
|
|
else:
|
|
attrs[field] = value
|
|
|
|
for field, paths in NESTED_ALIASES.items():
|
|
nested = _nested(payload, paths)
|
|
if nested is not None:
|
|
attrs[field] = _as_text(nested)
|
|
|
|
known = set(SESSION_ALIASES) | {"hook_event_name", "hookEventName", "timestamp"}
|
|
for aliases in FIELD_ALIASES.values():
|
|
known.update(aliases)
|
|
for paths in NESTED_ALIASES.values():
|
|
known.update(outer for outer, _ in paths)
|
|
extra = {
|
|
key: value
|
|
for key, value in payload.items()
|
|
if key not in known and isinstance(value, EXTRA_SCALAR_TYPES)
|
|
}
|
|
if extra:
|
|
attrs["extra"] = extra
|
|
|
|
# A trace has to say what its harness *could* have reported, or a scorer
|
|
# cannot tell "never happened" from "not observable here".
|
|
if event == "session.start":
|
|
attrs["harness"] = source
|
|
attrs["completeness"] = list(schema.HARNESS_CAPABILITIES.get(source, ()))
|
|
|
|
return {key: value for key, value in attrs.items() if value is not None}
|
|
|
|
|
|
def build(source: str, payload: dict, explicit_event: str | None, session: str | None):
|
|
event = resolve_event(source, payload, explicit_event)
|
|
if event is None:
|
|
return None, None, None
|
|
session_id = session or _first(payload, SESSION_ALIASES) or os.environ.get(
|
|
"WIKITOOL_SESSION_ID"
|
|
)
|
|
return event, normalise(source, event, payload), session_id
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--source", required=True, choices=sorted(EVENT_MAPS))
|
|
parser.add_argument(
|
|
"--event",
|
|
help="Our event name, e.g. tool.pre. Required for harnesses that do not "
|
|
"name the event in the payload (Copilot CLI).",
|
|
)
|
|
parser.add_argument("--session", help="Override the session id from the payload.")
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print the event instead of appending it. Never use this from a hook: "
|
|
"some harnesses read a hook's stdout as a decision.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Bytes, decoded here: a harness writes its payload as UTF-8, and Python on
|
|
# Windows would decode `sys.stdin` with the locale's code page (Gitea #152).
|
|
raw = ""
|
|
if not sys.stdin.isatty():
|
|
raw = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
try:
|
|
payload = json.loads(raw) if raw.strip() else {}
|
|
except json.JSONDecodeError:
|
|
payload = {}
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
|
|
event, attrs, session = build(args.source, payload, args.event, args.session)
|
|
if event is None:
|
|
if args.dry_run:
|
|
sys.stderr.write("no event mapping for this payload\n")
|
|
return 1
|
|
return 0
|
|
|
|
if args.dry_run:
|
|
record = schema.make_event(
|
|
args.source, event, attrs, session_id=session or "dry-run", seq=1
|
|
)
|
|
errors = schema.validation_errors(record)
|
|
if errors:
|
|
sys.stderr.write("; ".join(errors) + "\n")
|
|
return 1
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
sys.stdout.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
return 0
|
|
|
|
write_event(args.source, event, attrs, session=session)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except SystemExit:
|
|
raise
|
|
except BaseException: # noqa: BLE001 - an observer never breaks the observed
|
|
sys.exit(0)
|