Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7d94542
feat(trace): record per-API-call detail rows beside every turn row
rodosingh Sep 8, 2026
df3fb0d
feat(trace): price calls from a shipped rate card
rodosingh Sep 8, 2026
2f9247a
feat(trace): harvest GEAK's spend from its Claude Code transcripts
rodosingh Sep 8, 2026
8f53bd8
feat(tools): add the Hyperloom and GEAK per-LLM-call report generators
rodosingh Sep 8, 2026
bd0fd0c
docs(skills): add the hyperloom-llm-report and geak-llm-report skills
rodosingh Sep 8, 2026
ce1882c
test(trace): cover call detail, pricing, harvest and both report tools
rodosingh Sep 8, 2026
530ed40
feat(trace): carry cost, timing and task path through to Langfuse
rodosingh Sep 8, 2026
3fae3ba
feat(tools): render a GEAK run as one structured HTML page
rodosingh Sep 8, 2026
282b8c8
docs(skills): document the HTML renderer in the geak-llm-report skill
rodosingh Sep 8, 2026
3361870
feat(tools): lead the GEAK report with throughput, and keep the page …
rodosingh Sep 8, 2026
5122612
fix(tools): report the measured kernel A/B, not the opbench ceiling
rodosingh Sep 9, 2026
6167613
fix(tools): stop the per-phase columns overlapping, and tell GEAK who…
rodosingh Sep 9, 2026
da9e352
feat(tools): render the outcome half when a run's LLM ledger is gone
rodosingh Sep 9, 2026
9d9868f
feat(tools): name the kernel each HeadKernel phase went after
rodosingh Sep 9, 2026
91ae404
refactor(tools): share the HTML report chrome between renderers
rodosingh Sep 9, 2026
df9d65d
feat(tools): render a session-wide HTML report, not just the GEAK phase
rodosingh Sep 9, 2026
17f6344
test(tools): pin the session report's counting and its refusals to guess
rodosingh Sep 9, 2026
a74875b
docs(skills): point the report skill at the HTML renderer
rodosingh Sep 9, 2026
074382e
fix(tools): stop counting a call twice when its producer wrote no cal…
rodosingh Sep 9, 2026
10261a9
feat(cli): write the session HTML report on every finished run
rodosingh Sep 10, 2026
f116a84
feat(tools): give the session report the GEAK report's per-phase depth
rodosingh Sep 10, 2026
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ hyperloom = [
# Release-contract shell installers + configs.
"assets/*.sh",
"assets/*.example",
"assets/*.yaml",
"assets/configs/*.yaml",
"assets/agentx/*.sh",
"assets/agentx/*.py",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# SPDX-FileCopyrightText: 2026 Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT

"""GEAK is told who invoked it, because it also runs standalone."""

from __future__ import annotations

import json
from pathlib import Path
from types import SimpleNamespace

from hyperloom.agents.kernel.tools.backends import geak_runner


def _fake_popen(captured: dict) -> object:
def popen(cmd, **kwargs): # noqa: ANN001, ANN003
captured["env"] = kwargs.get("env") or {}
return SimpleNamespace(
pid=1,
returncode=0,
communicate=lambda timeout=None: ("", ""),
)

return popen


def test_geak_is_told_that_hyperloom_invoked_it(monkeypatch, tmp_path: Path):
"""GEAK names its end-of-run report after the harness that drove the run.

Standalone GEAK and GEAK-as-KERNEL_AGENT answer different questions and their
numbers are not comparable, so the two must not produce identically-named
reports in a directory someone later reads without context.
"""
runner = tmp_path / "interface" / "run_e2e.py"
runner.parent.mkdir(parents=True)
runner.write_text("")
monkeypatch.setenv("GEAK_E2E_RUNNER", str(runner))
monkeypatch.delenv("GEAK_INVOKED_BY", raising=False)

captured: dict = {}
monkeypatch.setattr(geak_runner.subprocess, "Popen", _fake_popen(captured))
(tmp_path / "result.json").write_text(json.dumps({"ok": True}))

geak_runner.call_geak({"model_path": "/m"}, tmp_path, timeout_s=120)

assert captured["env"]["GEAK_INVOKED_BY"] == "hyperloom"
4 changes: 4 additions & 0 deletions src/hyperloom/agents/kernel/tools/backends/geak_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ def call_geak(handoff: dict, output_dir: Path, *, timeout_s: int = 43200, python
cmd = [py, runner, str(handoff_path), str(result_path)]

env = dict(os.environ)
# Tell GEAK who is driving. It runs standalone too, and the two modes answer
# different questions, so its end-of-run report names itself hl_* rather than
# geak_* when this is set. A GEAK build that predates the marker ignores it.
env["GEAK_INVOKED_BY"] = "hyperloom"
# ``timeout_s`` is authoritative: run_e2e.py reads GEAK_E2E_TIMEOUT_S to
# self-stop before the outer subprocess kill. Split the inner SOFT deadline
# from the outer HARD kill so run_e2e can flush result.json before SIGKILL.
Expand Down
80 changes: 74 additions & 6 deletions src/hyperloom/common/llm_attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"CLAW_SESSION_ID_ENV",
"DEFAULT_APPLICATION",
"PRESETS",
"TASK_PATH_SEPARATOR",
"attribution_context",
"call_headers",
"current_action",
Expand All @@ -75,7 +76,10 @@
"current_phase",
"inject_env",
"sdk_env_overlay",
"current_task_path",
"current_task_path_str",
"set_current_phase",
"task_scope",
]

# Publishing the phase here rather than threading it through every signature is
Expand Down Expand Up @@ -147,6 +151,63 @@ def current_action() -> str:
return _current_action.get()


# The task path is nested where the action is flat: one action fans out into
# sub-agents, specialists and per-kernel attempts, and a report has to be able
# to attribute a call to the leaf that made it. A context variable gives that
# for free -- a child task sees its parent's path, pushes its own segment onto
# it, and its siblings never observe the push.
_current_task_path: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
"hyperloom_llm_attribution_task_path",
default=(),
)

TASK_PATH_SEPARATOR = "/"


@contextlib.contextmanager
def task_scope(segment: str) -> Iterator[None]:
"""Push one segment onto the task path for the duration of a block.

Pushes rather than replaces, so nesting composes: a phase scope wrapping an
action scope wrapping a per-kernel scope yields
``KERNEL_AGENT/kernel_opt/gemm_fp8``. An empty or all-separator segment is
dropped rather than producing a blank level.

Args:
segment: The label for this level of the tree.

Yields:
``None``, with the segment appended for the duration of the block.
"""
clean = _sanitize(segment).strip(TASK_PATH_SEPARATOR)
if not clean:
yield
return
token = _current_task_path.set(_current_task_path.get() + (clean,))
try:
yield
finally:
_current_task_path.reset(token)


def current_task_path() -> tuple[str, ...]:
"""Return the task-path segments this code is running inside.

Returns:
The segments outermost-first, empty outside any :func:`task_scope`.
"""
return _current_task_path.get()


def current_task_path_str() -> str:
"""Return the current task path rendered as a single separated string.

Returns:
The joined path, or ``""`` outside any :func:`task_scope`.
"""
return TASK_PATH_SEPARATOR.join(current_task_path())


def _sanitize(value: object) -> str:
"""Strip anything that would corrupt an encoding the value passes through.

Expand Down Expand Up @@ -209,6 +270,7 @@ def attribution_context(
"phase": current_phase() if phase is None else phase,
"type": current_action(),
"operation": operation,
"task_path": current_task_path_str(),
**extra,
}
return {key: text for key, value in fields.items() if (text := _sanitize(value))}
Expand Down Expand Up @@ -283,20 +345,21 @@ def _parse_json(fields: Sequence[str], value: str) -> dict[str, str]:

#: Fields a child may take from the tag its parent wrote. They describe *where*
#: a call happens rather than what makes it: ``session`` identifies the run, and
#: ``phase`` and ``type`` are ambient state that lives in one process only --
#: :data:`_current_phase` is a module global and :data:`_current_action` a
#: context variable, so a spawned child starts with both empty and could not
#: restate them if it wanted to. ``application`` is absent because this module
#: ``phase``, ``type`` and ``task_path`` are ambient state that lives in one
#: process only -- :data:`_current_phase` is a module global and
#: :data:`_current_action` and :data:`_current_task_path` are context variables,
#: so a spawned child starts with all three empty and could not restate them if
#: it wanted to. ``application`` is absent because this module
#: always supplies it, so an inherited copy could never be reached. ``component``
#: and ``operation`` are absent by intent: a call site that names itself is
#: declaring a new producer, and inheriting the parent's purpose would label its
#: calls with work they are not doing.
_INHERITED_FIELDS = ("session", "phase", "type")
_INHERITED_FIELDS = ("session", "phase", "type", "task_path")

#: The inherited fields describing the *running process* rather than the run's
#: identity. Only a genuinely different process may take these; see
#: :func:`inject_env` for why re-reading them into their own writer is unsound.
_AMBIENT_FIELDS = ("phase", "type")
_AMBIENT_FIELDS = ("phase", "type", "task_path")


@dataclass(frozen=True)
Expand Down Expand Up @@ -328,6 +391,11 @@ class AttributionHeader:
# Sets the spend log's session_id column and propagates to nested MCP and
# A2A calls, so it is the column a per-session reconciliation joins on.
AttributionHeader("x-litellm-trace-id", "raw", ("session",)),
# The task path is deliberately *not* a tag: it carries kernel ids and
# attempt numbers, and spreading that cardinality across the rollup tag
# is what would stop it rolling up. It rides its own header, in the
# self-describing shape so a spawned child can inherit it back.
AttributionHeader("x-hyperloom-task-path", "combined", ("task_path",)),
),
}

Expand Down
71 changes: 71 additions & 0 deletions src/hyperloom/inference_optimizer/assets/llm_pricing.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# SPDX-FileCopyrightText: 2026 Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
#
# Rate card used to derive USD for LLM calls whose provider reports no cost.
# Consumed by hyperloom.orchestrator.trace.pricing; override the whole file
# with $HYPERLOOM_LLM_PRICING_FILE.
#
# Keys are normalized model ids (lowercased, provider prefix and any
# ":<tag>" suffix stripped) and are matched longest-prefix-first, so
# "claude-opus-4" covers "claude-opus-4-8-20260101". Rates are USD per
# million tokens. Reasoning tokens bill at the output rate; a model that
# does not price caching separately leaves cache_read/cache_write unset and
# falls back to the input rate.

version: 1

defaults:
# Applied to a matched model for any rate it does not set itself.
cache_read: null
cache_write: null

models:
claude-opus-5:
input: 5.0
output: 25.0
cache_read: 0.5
cache_write: 6.25
claude-opus-4:
input: 15.0
output: 75.0
cache_read: 1.5
cache_write: 18.75
claude-sonnet-5:
input: 3.0
output: 15.0
cache_read: 0.3
cache_write: 3.75
claude-sonnet-4:
input: 3.0
output: 15.0
cache_read: 0.3
cache_write: 3.75
claude-haiku-4:
input: 1.0
output: 5.0
cache_read: 0.1
cache_write: 1.25
claude-3-5-haiku:
input: 0.8
output: 4.0
cache_read: 0.08
cache_write: 1.0

# Self-hosted models served through the internal gateway. The gateway bills
# no dollars, so these rates express the rented-GPU equivalent and exist so
# a mixed run still totals in one unit. Edit them rather than the code.
gpt-oss-120b:
input: 0.15
output: 0.6
qwen3-14b:
input: 0.06
output: 0.24
qwen3:
input: 0.06
output: 0.24
minimax-m3:
input: 0.3
output: 1.2
llama-3:
input: 0.1
output: 0.4
Loading