Files
chemenu/tools/trace_ingest.py
T
torben d0f08d1fba
CI / verify (push) Successful in 2m42s
CI / pwsh (push) Successful in 1m55s
Release / release (push) Successful in 36s
feat: Windows portability - path separators, LF line endings, UTF-8 decoding and output, msvcrt lock fallback (#152)
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
2026-10-01 21:15:03 +02:00

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)