Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5b59a38
Add a template_fingerprint that also masks known blockchain names.
Danswar Aug 31, 2026
7c47eb9
Add error_issue_act: file or update a GitHub issue per error template.
Danswar Aug 31, 2026
c0e77dd
Also mask known asset tickers in the template signature.
Danswar Aug 31, 2026
9edb868
Add burst detection and a per-template cooldown to error_issue_act.
Danswar Aug 31, 2026
d4f7b00
Match known chain and asset names only as whole tokens.
Danswar Sep 1, 2026
17ba214
Handle every pending row of one error template in one pass.
Danswar Sep 1, 2026
be5c0c4
Document the error.issue activity type and its deferred dispatch.
Danswar Sep 1, 2026
e02e66f
Apply the issue body ceiling on the create paths too.
Danswar Sep 1, 2026
e54649c
Document template_fingerprint in the error.seen payload shape.
Danswar Sep 1, 2026
5be2222
Match the issue marker against the body instead of trusting gh search.
Danswar Sep 1, 2026
752892d
Describe the masked names by what the platform exposes.
Danswar Sep 1, 2026
d2b80d8
Fail loud when the issue list is truncated.
Danswar Sep 1, 2026
839ff5b
Harden the issue body against untrusted text, unbounded growth, and b…
Danswar Sep 1, 2026
626cecf
Keep environments apart in the burst issue label.
Danswar Sep 1, 2026
18df18b
Remember burst folds locally so a capped table cannot split a template.
Danswar Sep 1, 2026
26cf1ac
Bind a remembered burst fold to the issue it was folded into.
Danswar Sep 1, 2026
ad979aa
Give every template its own burst row regardless of separators in its…
Danswar Sep 1, 2026
7516694
Serialize template fingerprint fields unambiguously.
Danswar Sep 1, 2026
73efb7d
Harden the issue body against untrusted markers, fences and cross-rep…
Danswar Sep 1, 2026
ae02dcf
Document the error.issue payload shape.
Danswar Sep 1, 2026
099b2ff
Say what the code does: duplicate markers, payment rails, row bounds.
Danswar Sep 1, 2026
38b1fbe
Exclude the fresh-body case from the damaged-section wording.
Danswar Sep 1, 2026
4ff8f88
Recognise the variants header by its cells, not by a prefix.
Danswar Sep 1, 2026
1522799
Keep a gh argument error on its row and log text inert in a cell.
Danswar Sep 1, 2026
3a039c9
Say why the alternation is sorted, and document escaping and comments.
Danswar Sep 1, 2026
29757c4
Fetch a burst body once per scan even when it is damaged.
Danswar Sep 1, 2026
5d1866f
Describe the create path as it is: no comment, one opening table.
Danswar Sep 1, 2026
70d8422
Split create from update in the design notes too.
Danswar Sep 1, 2026
a5139de
Check the marker on the body that actually gets written.
Danswar Sep 1, 2026
993a839
Anchor known names on Unicode word boundaries.
Danswar Sep 1, 2026
a4d8a8d
Guard the body that is written and the fields that go public.
Danswar Sep 1, 2026
aff90e1
Do not fall back to a prefix ticker when the longer one is glued.
Danswar Sep 1, 2026
269c68e
Mean word character where the boundary means word character.
Danswar Sep 1, 2026
d638481
Require a created issue to name a URL before it counts as touched.
Danswar Sep 1, 2026
f58e24e
Hash redacted metadata into the template fingerprint.
Danswar Sep 1, 2026
55ff023
Hold a recorded issue reference to the shape the writers produce.
Danswar Sep 1, 2026
3e9c924
Note in the design notes that the hashed metadata is redacted.
Danswar Sep 1, 2026
6ec9780
Salt the public marker instead of blurring the grouping key.
Danswar Sep 1, 2026
bb6cc6f
Salt and guard the burst row digest the same way.
Danswar Sep 1, 2026
cc91a88
Drop error.issue GitHub-issue filing
Danswar Sep 1, 2026
8d32838
Fix a dangling reference left after the previous removal
Danswar Sep 1, 2026
9961b16
Reword docstrings left over from the issue-filing removal
Danswar Sep 1, 2026
c536db1
Fix a second stale comment missed by the first pass
Danswar Sep 1, 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
12 changes: 12 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,7 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar
```json
{
"fingerprint": "service|class|stack-sig|env",
"template_fingerprint": "service|class|template-sig|env",
"service": "api",
"environment": "prod",
"class": "TimeoutError",
Expand All @@ -634,6 +635,17 @@ Log lines, stack traces, and error messages are untrusted data (§19.2). They ar
}
```

`template_fingerprint` is `fingerprint` one step coarser: known blockchain and
payment-rail names and asset tickers are masked before hashing, so per-chain and
per-token variants of one error share it. Names are masked only as whole tokens
and case-sensitively, so prose like "Based" or lowercase "usd" is not mistaken
for a chain or a ticker. `service`, `class` and `environment` are
percent-escaped (`%`→`%25`, `|`→`%7C`) before the join, so two different field
tuples cannot serialize to one fingerprint. They keep their raw values:
grouping has to stay injective, or two tenants whose labels merely look alike
after redaction would be grouped as one. `fingerprint` stays the finer-grained
identity used for `count` / `last_seen`, so per-variant dedup remains exact.

`repo` may be omitted when the adapter cannot map the stream; the session then `error.skip`s with reason `unmapped-repo`. `line_fingerprint` is optional: `sha256(server + newline + container + newline + exact line)` as 64 lowercase hex, computed from the raw line before redaction. Omit it when `server` or `container` is missing. Host adapters may print the hex on `error.fix` stdout; it is not a mandate and not a log-host name.

### 21.4 Analysis and eligibility
Expand Down
145 changes: 145 additions & 0 deletions src/agent_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,88 @@
_UUID = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
# The blockchain and payment-rail names the platform already exposes as transfer
# options, current as of 2026-08-31 — masked in the template signature so a
# per-chain error variant ("Timeout updating balances for Ethereum" vs "...for
# Polygon") groups under one coarser template instead of fragmenting one
# fingerprint per chain. Refresh when that list changes; there is no automated sync.
_KNOWN_CHAINS = frozenset(
{
"DeFiChain", "Ethereum", "Arbitrum", "Polygon", "BinanceSmartChain", "Binance",
"Kraken", "Base", "Optimism", "Citrea", "MEXC", "XT", "Railgun", "Sumixx",
"InternetComputer", "Scrypt", "Sepolia", "MaerkiBaumann", "Checkout", "Kaleido",
"Solana", "Zano", "Frick", "Tron", "Yapeal", "Talium", "OlkyFrozen", "Haqq",
"Bitcoin", "CitreaTestnet", "Gnosis", "KucoinPay", "Spark", "Lightning", "Firo",
"Olkypay", "BitcoinTestnet4", "BinancePay", "Arkade", "Cardano", "Monero",
}
)


def _token_pattern(names: frozenset[str]) -> re.Pattern[str]:
"""Alternation over known names, longest-first, anchored on both sides.

The anchors alone settle names made only of word characters: "Bitcoin"
inside "BitcoinTestnet4" fails its own trailing look-ahead, so the engine
backtracks to the longer alternative whatever the order. Ordering is what
settles the rest — "USDC" inside "USDC.e" is followed by ".", which is not a
word character, so the short alternative would match and strip the ticker
down to the wrong asset unless the longer one is tried first.

Anchored on both sides with a word-character look-around so a name only
matches as a whole token. That class is Unicode-aware, so a ticker glued to
non-Latin letters or to an underscore is not a ticker either:
without that, "Base" matches inside "Based", "SOL" inside "RESOLVE", "COMP"
inside "COMPLETE" and "DAI" inside "DAILY", which would mask unrelated words
and label an unrelated error as a chain/asset variant. The anchors are
explicit look-arounds rather than \b because several names are not
word-character-only ("USDC.e"). Matching stays case-sensitive on purpose:
lowercase prose words like "usd" in "token tether -> usd" are not tickers."""
ordered = sorted(names, key=len, reverse=True)
alternatives: list[str] = []
for name in ordered:
# A shorter name that merely prefixes a longer one is normally settled by
# trying the longer one first. That breaks when the longer one continues
# with a non-word character: "USDC.e" glued to more text fails its own
# trailing boundary, and the engine falls back to "USDC", whose boundary
# passes because "." is not a word character. Block those continuations
# so the short name loses too, exactly as it would inside "USDC_balance".
blockers = "".join(
f"(?!{re.escape(longer[len(name):])})"
for longer in ordered
if longer.startswith(name)
and len(longer) > len(name)
# Word characters, not just alphanumerics: an underscore continuation
# is already handled by the trailing boundary below, so the predicate
# has to mean the same thing that boundary does.
and re.match(r"\w", longer[len(name)]) is None
)
alternatives.append(re.escape(name) + blockers)
return re.compile(rf"(?<!\w)(?:{'|'.join(alternatives)})(?!\w)")


_CHAIN_TOKEN = _token_pattern(_KNOWN_CHAINS)
# Asset tickers the platform lists, current as of 2026-08-31, masked the same way
# as chains — e.g. "Balance for Arbitrum/USDC went..." vs ".../WBTC went..." would
# otherwise stay separate templates. Kept to tickers available on 2+ chains (a
# defensible cut against one-off and legacy stock-tokenization artifacts), plus two
# single-chain tickers (GMX, TGT) that the 2+-chains cut would otherwise have
# missed even though real balance-check errors named them. Refresh when the listed
# assets change; there is no automated sync.
_KNOWN_ASSETS = frozenset(
{
"1INCH", "AAVE", "ADA", "APE", "ARB", "AXS",
"BAT", "BNB", "BTC", "CHF", "CHZ", "COMP",
"CRV", "DAI", "DEPS", "DFI", "ENJ", "ETH",
"EUR", "EURC", "EURS", "EURt", "GRT", "JUSD",
"LINK", "MANA", "MATIC", "MKR", "ONDO", "POL",
"QNT", "REALU", "RPL", "SAND", "SNX", "SOL",
"SUSHI", "TRX", "TUSD", "UNI", "USD", "USDC",
"USDC.e", "USDT", "WBTC", "WETH", "WFPS", "XCHF",
"XMR", "ZANO", "ZCHF", "cBTC", "dEURO", "GMX",
"TGT",
}
)
_ASSET_TOKEN = _token_pattern(_KNOWN_ASSETS)
_DIGITS = re.compile(r"\d+")
_SPACE = re.compile(r"\s+")
_CREDENTIAL_KEYS = frozenset(
Expand Down Expand Up @@ -208,6 +290,61 @@ def stack_sig(line: str) -> str:
return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]


def template_signature(line: str) -> str:
"""Coarser than stack_sig: also masks known blockchain and payment-rail
names and asset tickers (see _KNOWN_CHAINS/_KNOWN_ASSETS), so a per-chain or
per-token error variant groups under one coarser template instead of
fragmenting into one fingerprint per chain/token pair. error.seen identity
keeps using the finer-grained fingerprint()/stack_sig() so per-variant
count/last_seen tracking stays exact."""
norm = redact(strip_ansi(line))
norm = _UUID.sub("", norm)
norm = _CHAIN_TOKEN.sub("<CHAIN>", norm)
norm = _ASSET_TOKEN.sub("<ASSET>", norm)
norm = _DIGITS.sub("", norm)
norm = _SPACE.sub(" ", norm).strip().lower()
return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]


def _escape_field(value: str) -> str:
"""Make a field safe to join with "|".

service, error_class and environment are free text from the log source, so
an unescaped join is ambiguous: service="a", error_class="b|c" and
service="a|b", error_class="c" would produce the same fingerprint and group
two unrelated errors under one template. Percent-escaping "%" first and then
"|" is reversible, so distinct field tuples stay distinct."""
return value.replace("%", "%25").replace("|", "%7C")


def template_fingerprint(
*, service: str, error_class: str, template_sig: str, environment: str
) -> str:
return "|".join(
(
_escape_field(service),
_escape_field(error_class),
template_sig,
_escape_field(environment),
)
)


def known_chain_in(line: str) -> str | None:
"""The first known chain or payment-rail name present in the line, if any.
_KNOWN_CHAINS covers both, since the platform exposes them as one set of
transfer options. Most error lines name neither; those return None."""
match = _CHAIN_TOKEN.search(line)
return match.group(0) if match is not None else None


def known_asset_in(line: str) -> str | None:
"""The first known asset ticker present in the line, if any — same purpose
as known_chain_in, for the token half of a chain/token variant label."""
match = _ASSET_TOKEN.search(line)
return match.group(0) if match is not None else None


def _strip(row: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in row.items() if not k.startswith("_")}

Expand Down Expand Up @@ -472,6 +609,12 @@ def _apply_lines(
stack_sig=stack_sig(redacted),
environment=environment,
)
template_fp = template_fingerprint(
service=service,
error_class=cls,
template_sig=template_signature(redacted),
environment=environment,
)
server = item.get("server")
container = item.get("container")
line_fp = None
Expand All @@ -490,6 +633,7 @@ def _apply_lines(
payload_obj["count"] = count + 1
payload_obj["last_seen"] = ts
payload_obj["excerpt"] = excerpt
payload_obj["template_fingerprint"] = template_fp
if line_fp is not None:
payload_obj["line_fingerprint"] = line_fp
else:
Expand All @@ -503,6 +647,7 @@ def _apply_lines(
aid = str(uuid.uuid4())
payload_obj = {
"fingerprint": fp,
"template_fingerprint": template_fp,
"service": service,
"environment": environment,
"class": cls,
Expand Down
Loading
Loading