feat(explore): hvilken base som faktisk ble AAPNET forlater kjoeringen (MAJOR-1 a, ORDRE 20260902T151931Z)

`{run_id}-exploration.json` bar syv noekler og ingen rad for verktoey: en
utforskning som kalte NULL verktoey var uskillbar fra en som navigerte hele
korpuset, baade offline og etter en BETALT kjoering. `ExplorationToolRecorder`
er en `FunctionMiddleware` paa utforskningsagentene som registrerer navnet og
`bundle_id`-argumentet, i kall-rekkefoelge, paa den kaller-eide
`ExplorationTrace` — og `trace_payload` skriver det ved siden av
`quick_validations`.

SOESKEN av `mcp_tools.ToolCallRecorder`, ALDRI en gjenbruk: invariantene er
motsatte. Den filtrerer til KONFIGURERTE eksterne verktoey, dedupliserer per
`(server, tool)` og returnerer SORTERT, fordi dens rad er en egress-paastand i
et byte-deterministisk artefakt. Denne beholder hvert kall i REKKEFOELGE uten
dedup, fordi spoersmaalet er det motsatte: aapnet navigatoeren noe, og i hvilken
sekvens. Et sortert dedupet sett kan ikke skille en generalproeve som LESTE en
base fra en som bare listet dem. Aa generalisere den ene til aa tjene begge
ville brutt den andre.

RESULTATET registreres ALDRI — det er basens innhold, altsaa nettopp det som er
for stort til aa ri med (MAJOR-3 maalte 73-93 % av alle prompt-tokens), og et
spor som bar det ville vaert en andre kopi av konteksten. Middlewaren observerer
kun; `call_next` ventes alltid.

MAALT FOERST (Iron Law): fire tester roede mot HEAD foer sommen fantes.
`FunctionInvocationContext.arguments` er `BaseModel | Mapping[str, Any]` (maalt
mot den installerte signaturen), saa BEGGE former leses; et verktoey uten
`bundle_id` gir `""`, aldri en oppdiktet etikett. Wiret i BEGGE
workflow-byggene — `explore()` OG `resume_exploration()` — ellers ville et
gjenopptatt leg registrert null verktoeykall, samme stille gap som
`plan_reviews.extend` i oekt 64.

Suite 2 failed (F15-diffen, KJENT) / 1082 passed / 5 skipped (1089 samlet, +4).
Golden `demo-transcript.stdout` BYTE-UENDRET
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-02 17:49:45 +02:00
commit 4aa4f9c429
2 changed files with 218 additions and 4 deletions

View file

@ -25,12 +25,20 @@ from __future__ import annotations
import json
import sys
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Final, Literal, TextIO
from agent_framework import Agent, BaseChatClient, FileCheckpointStorage, FunctionTool, tool
from agent_framework import (
Agent,
BaseChatClient,
FileCheckpointStorage,
FunctionInvocationContext,
FunctionMiddleware,
FunctionTool,
tool,
)
from agent_framework.orchestrations import (
MagenticBuilder,
MagenticOrchestratorEventType,
@ -253,6 +261,67 @@ class QuickValidation:
verdict: Mapping[str, Any]
@dataclass(frozen=True)
class ToolCall:
"""One exploration tool invocation: what was asked for, never what came back.
``bundle_id`` is the base the call named, and is ``""`` for a tool that takes none
(``list_bundles``). The RESULT is deliberately absent: it is the base's content, which is the
very thing that is too big to ride along (MAJOR-3 measured it at 73-93 % of all prompt tokens),
and a trace carrying it would be a second copy of the context rather than a record of the run.
"""
name: str
bundle_id: str
def _bundle_argument(arguments: Any) -> str:
"""The ``bundle_id`` a call named, from either shape ``FunctionInvocationContext`` allows.
``arguments`` is typed ``BaseModel | Mapping[str, Any]`` (measured against the installed
signature), so both are read rather than one being assumed. A tool without the parameter or
a value that is not a string yields ``""``: the recorder describes the call, and inventing a
label for a base that was never named would be the false-attribution that ``ToolCallRecorder``
refuses for unconfigured tools.
"""
if isinstance(arguments, Mapping):
value: Any = arguments.get("bundle_id")
else:
value = getattr(arguments, "bundle_id", None)
return value if isinstance(value, str) else ""
class ExplorationToolRecorder(FunctionMiddleware):
"""Records WHICH exploration tool an agent actually called, in the order it called them.
**A sibling of ``mcp_tools.ToolCallRecorder``, never a reuse of it** the same shape, one
layer over: that one observes the debate's EXTERNAL tool calls, this one the exploration's
IN-PROCESS ones. Their invariants are opposites and generalising one to serve both would break
the other. ``ToolCallRecorder`` filters to configured servers, de-duplicates per
``(server, tool)`` and returns SORTED, because its record is an egress claim stamped into a
byte-deterministic artefact. This one keeps every call in INVOCATION ORDER without dedup,
because the question it answers is the opposite one: did the navigator open anything, and in
what sequence. A sorted, de-duplicated set cannot tell a rehearsal that read a base from one
that only listed them, which is the whole of MAJOR-1.
It observes only ``call_next`` is always awaited, and nothing here can block, alter or
short-circuit an invocation. A trace that changed the run it traces would not be a trace.
"""
def __init__(self, sink: list[ToolCall]) -> None:
self._sink = sink
async def process(
self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]
) -> None:
name = getattr(getattr(context, "function", None), "name", None)
if isinstance(name, str):
self._sink.append(
ToolCall(name=name, bundle_id=_bundle_argument(getattr(context, "arguments", None)))
)
await call_next()
@dataclass
class ExplorationTrace:
"""The CALLER-owned accumulator for everything one exploration produced along the way.
@ -274,6 +343,12 @@ class ExplorationTrace:
ledger: list[LedgerEntry] = field(default_factory=list)
plan_reviews: list[PlanReview] = field(default_factory=list)
quick_validations: list[QuickValidation] = field(default_factory=list)
#: Every exploration tool call, in order (MAJOR-1). It sits BESIDE ``quick_validations``
#: rather than inside it: that list is the level-1 VERDICTS the hypothesiser saw, this is
#: whether any base was opened at all. An offline rehearsal that called nothing looks exactly
#: like a successful one on every other field, which is what made the dress rehearsal vacuous
#: by construction and unreadable after the fact.
tool_calls: list[ToolCall] = field(default_factory=list)
#: Tokens spent so far, refreshed as the loop turns rather than written once at the end. The
#: meter is internal to ``explore``, so this is the only way the artefact can report a spend —
#: and updating it per iteration is what makes it readable for a run a cap cut short, which is
@ -325,6 +400,9 @@ def trace_payload(trace: ExplorationTrace, *, stop: str | None, completed: bool)
}
for call in trace.quick_validations
],
"tool_calls": [
{"name": call.name, "bundle_id": call.bundle_id} for call in trace.tool_calls
],
}
@ -1285,7 +1363,7 @@ async def explore(
client_factory,
contract=contract,
bundle_dirs=bundle_dirs,
middleware=[BudgetMiddleware(meter)],
middleware=[BudgetMiddleware(meter), ExplorationToolRecorder(trace.tool_calls)],
quick_validate_sink=trace.quick_validations,
checkpoint_dir=checkpoint_dir,
)
@ -1565,7 +1643,7 @@ async def resume_exploration(
client_factory,
contract=parked.contract,
bundle_dirs=parked.bundle_dirs,
middleware=[BudgetMiddleware(meter)],
middleware=[BudgetMiddleware(meter), ExplorationToolRecorder(trace.tool_calls)],
quick_validate_sink=trace.quick_validations,
checkpoint_dir=checkpoint_dir,
)