Skip to content

Commit fbbb952

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-20-env-discovery
2 parents 563a567 + 74a4de8 commit fbbb952

7 files changed

Lines changed: 92 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Entries are newest-last within a release, matching the order they were written.
99

1010
## Unreleased
1111

12+
- a run **stopped for overspending reported spending nothing**. Tokens were attributed from `end` events, and a node the budget interrupts emits `error` instead — so `grapharc metrics` answered `tokens: 0` for a run whose own enforcement message named the figure that stopped it (`max_tokens reached (51/5)`). The audit trail lost precisely the number the stop was about, and per-node attribution dropped the most expensive node in the run. Every `error` event is now stamped with what its node spent, exactly as `end` is, and both `summarize` and the cost report count it; sub-events inside a node remain a breakdown of its total rather than an addition, so the disjointness that kept `ends + orphans` from double-counting is unchanged, and `RunCost.tokens == RunMetrics.tokens` still holds.
1213
- the `.env` credential loader **walked up parent directories to `/`**, while the config layer next door refuses exactly that on principle — so the file that *spends money* was discovered more eagerly than the one that *constrains* a run. A run started in a scratch subdirectory picked up an `OPENROUTER_API_KEY` from any ancestor: a `.env` in `$HOME` billed every user's experiment on a shared box to that key, a demo checked out under a client project quietly used the client's key, and since `redact()` is the only thing that ever prints a key, nothing in normal operation said *which file paid*. The rationale `cli/config.py` wrote down for `grapharc.toml` — "a run must never be silently governed by a file in a directory you didn't know about" — applies with more force to the file that pays than to the file that restrains, so `find_env_file` now reads the start directory (default: the working directory) and no ancestor of it. **This is a behaviour change:** anyone relying on a parent-directory `.env` must move it into the directory they run from, `export` the variable, or pass `env_file=` naming the file. Neither escape hatch moved — a real environment variable still beats any file, and an explicit `env_file=` still reads a file anywhere on disk — and no "search boundary" was added in place of the walk, because stopping at a git root is still an upward search.
1314

1415
## 0.1.3

docs/cookbook/01-basics.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -803,7 +803,7 @@ Output:
803803
{'attempt': 1, 'graph': 'counter', 'node': 'load', 'phase': 'start', 'step': 1}
804804
{'attempt': 1, 'graph': 'counter', 'node': 'load', 'phase': 'end', 'step': 1, 'state_delta': {'items': ['a', 'b', 'c']}, 'tokens': 0}
805805
{'attempt': 1, 'graph': 'counter', 'node': 'count', 'phase': 'start', 'step': 2}
806-
{'attempt': 1, 'graph': 'counter', 'node': 'count', 'phase': 'error', 'step': 2, 'error': "ValueError('the counter is not implemented yet')"}
806+
{'attempt': 1, 'graph': 'counter', 'node': 'count', 'phase': 'error', 'step': 2, 'tokens': 0, 'error': "ValueError('the counter is not implemented yet')"}
807807
```
808808

809809
The four fields the snippet filtered out are on every line too: `ts` (ISO-8601 UTC),
@@ -823,8 +823,12 @@ So, by phase:
823823
node never returns.
824824
- **`end`** adds `state_delta` (exactly the validated update that was applied),
825825
`duration_ms`, and `tokens` charged during that node.
826-
- **`error`** adds `duration_ms` and `error``repr()` of the exception, so the type
827-
is preserved. There is no `state_delta`, because a node that raised wrote nothing.
826+
- **`error`** adds `duration_ms`, `error``repr()` of the exception, so the type is
827+
preserved — and `tokens`, the spend charged during that node before it failed.
828+
There is no `state_delta`, because a node that raised wrote nothing. The token
829+
count is there for the same reason `end` carries one: a run stopped *for*
830+
overspending used to report having spent nothing, because the only number the
831+
audit trail read was on the event an interrupted node never writes.
828832

829833
**Why it works this way.** `start` and `end` share a step number; the pair is the
830834
node execution. That means step numbers do not order the file — read events in file

grapharc/observe/metrics.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,14 @@ def summarize(recorder: TraceRecorder, run_id: str) -> RunMetrics | None:
5353
# Work the reconstruction could not place inside any node. Disjoint from
5454
# `ends` by construction, so adding it cannot double-count a node total.
5555
orphans = replay(recorder, run_id).orphan_sub_events
56-
measured = [*ends, *orphans]
56+
# `errors` are measured too, and for the same reason `ends` are: the kernel
57+
# stamps a node's terminal event with what that node spent, whichever way it
58+
# ended. Counting only `end` meant a run *stopped for overspending* reported
59+
# `tokens: 0` — the audit trail losing precisely the spend that triggered
60+
# enforcement. Sub-events inside a node are a breakdown of its total rather
61+
# than an addition to it, so the disjointness that makes `ends + orphans`
62+
# safe holds here unchanged.
63+
measured = [*ends, *errors, *orphans]
5764
reason = None
5865
# Scanned across every event, not just `end`: an agent writes its
5966
# `termination_reason` on a `stop` event, and that is still why it stopped.

grapharc/observe/replay.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,12 @@ def tokens(self) -> int:
136136
token it spent in `orphan_sub_events`, and reporting zero for it was
137137
the audit trail contradicting itself. The two sets are disjoint, so
138138
nothing is counted twice.
139+
140+
A node that *failed* counts too. Its terminal `error` event carries what
141+
it spent, exactly as an `end` does, so excluding it here reported zero
142+
tokens for a run the budget stopped for spending too many.
139143
"""
140-
return sum(e.tokens for e in self.executions if e.ok) + sum(
144+
return sum(e.tokens for e in self.executions) + sum(
141145
e.tokens or 0 for e in self.orphan_sub_events
142146
)
143147

grapharc/runtime/graph.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -710,15 +710,29 @@ def _leave(
710710
try:
711711
result, delta = self._check_result(f"node {name!r}", writes, result)
712712
except (WritePermissionError, StateTypeError, GraphRoutingError) as err:
713-
emit("error", duration_ms=duration_ms, error=str(err))
713+
emit(
714+
"error",
715+
duration_ms=duration_ms,
716+
error=str(err),
717+
tokens=ctx.meter.tokens - tokens_before,
718+
)
714719
raise
715720

716721
# Tokens are charged mid-node by the usage callback, so this is the
717722
# first boundary at which a spend made inside the node can stop the run.
718723
try:
719724
ctx.meter.check_tokens()
720725
except BudgetExceeded as exc:
721-
emit("error", duration_ms=duration_ms, error=f"budget: {exc.reason}")
726+
# Stamped with what the node spent, exactly as `end` is. Without it
727+
# the run stopped *for overspending* and then reported spending
728+
# nothing, which is the audit trail losing the one number the stop
729+
# was about.
730+
emit(
731+
"error",
732+
duration_ms=duration_ms,
733+
error=f"budget: {exc.reason}",
734+
tokens=ctx.meter.tokens - tokens_before,
735+
)
722736
raise
723737

724738
# The provider's own price for every model call made inside this node,
@@ -765,8 +779,14 @@ async def awrapped(state: Any, config: RunnableConfig) -> Any:
765779
except BaseException as exc:
766780
# BaseException, not Exception: an async node is stopped by
767781
# cancellation, which is not an Exception, and a stop with no
768-
# trace line is a stop nobody can audit afterwards.
769-
emit("error", duration_ms=(time.perf_counter() - t0) * 1000, error=repr(exc))
782+
# trace line is a stop nobody can audit afterwards. Carries
783+
# the node's spend for the same reason `end` does.
784+
emit(
785+
"error",
786+
duration_ms=(time.perf_counter() - t0) * 1000,
787+
error=repr(exc),
788+
tokens=ctx.meter.tokens - tokens_before,
789+
)
770790
raise
771791
return self._leave(
772792
name,
@@ -800,7 +820,12 @@ def wrapped(state: Any, config: RunnableConfig) -> Any:
800820
# ^C, which is a KeyboardInterrupt and not an Exception, and a
801821
# stop with no trace line is a stop nobody can audit afterwards.
802822
# The exception is re-raised untouched; only the record is new.
803-
emit("error", duration_ms=(time.perf_counter() - t0) * 1000, error=repr(exc))
823+
emit(
824+
"error",
825+
duration_ms=(time.perf_counter() - t0) * 1000,
826+
error=repr(exc),
827+
tokens=ctx.meter.tokens - tokens_before,
828+
)
804829
raise
805830
return self._leave(
806831
name,

tests/test_budget_enforcement.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,3 +665,43 @@ def slow(state):
665665
def test_a_deadline_exceeded_is_a_budget_exceeded():
666666
"""Callers that already catch BudgetExceeded must keep catching timeouts."""
667667
assert issubclass(NodeDeadlineExceeded, BudgetExceeded)
668+
669+
670+
def test_a_run_stopped_for_overspending_reports_what_it_spent(tmp_path):
671+
"""The audit trail must not lose the spend the stop was about.
672+
673+
Tokens were attributed on `end` events only, and an interrupted node emits
674+
`error` instead — so a run killed *for* exceeding `max_tokens` reported
675+
`tokens: 0`, contradicting the enforcement message that named the figure.
676+
"""
677+
import json
678+
679+
from grapharc.observe.metrics import summarize
680+
from grapharc.observe.replay import replay
681+
from grapharc.observe.trace import TraceRecorder
682+
683+
class State(GraphARCState):
684+
out: str = ""
685+
686+
def spend(state: State) -> dict:
687+
model = ScriptedChatModel(responses=["x" * 200], on_exhausted="repeat")
688+
return {"out": str(model.invoke("hi").content)[:10]}
689+
690+
trace = TraceRecorder(tmp_path / "t.jsonl")
691+
g = GraphARC(State, name="overspend", trace=trace, budget=Budget(max_tokens=5))
692+
g.add_node("spend", spend, writes={"out"})
693+
g.add_edge(START, "spend")
694+
g.add_edge("spend", END)
695+
696+
with pytest.raises(BudgetExceeded) as caught:
697+
g.compile().invoke({})
698+
699+
spent = int(str(caught.value).split("(")[1].split("/")[0])
700+
assert spent > 0, "the meter charged something, or this test proves nothing"
701+
702+
run_id = json.loads((tmp_path / "t.jsonl").read_text().splitlines()[0])["run_id"]
703+
metrics = summarize(trace, run_id)
704+
assert metrics.tokens == spent, "the audit trail must agree with the enforcement"
705+
assert metrics.errors == 1
706+
# The cost report and the audit trail must never disagree.
707+
assert replay(trace, run_id).tokens == metrics.tokens

tests/test_cookbook_basics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ def count(state: State) -> dict:
565565
"node": "count",
566566
"phase": "error",
567567
"step": 2,
568+
"tokens": 0,
568569
"error": "ValueError('the counter is not implemented yet')",
569570
},
570571
]

0 commit comments

Comments
 (0)