Four verified defects in the outbound integrations: the Slack client wrapper, GrantBot + the funding regexes, the ORCID/PubMed/Grants HTTP layer, and one broken import.
Line numbers current as of origin/main @ b7edcbc (2026-07-30).
Suggested order: V10 (trivial, and unblocks C1's invite.py site in issue #22) → V7 → V9 → V8.
PR V7 — Slack client correctness cluster (small)
All in src/agent/slack_client.py:
_call_with_retry raises UnboundLocalError on rate-limit exhaustion — except … as exc unbinds exc at block exit, and the post-loop raise SlackApiError(response=exc.response) (:167) then references it. Language semantics reproduced 2026-07-30: UnboundLocalError: cannot access local variable 'exc' where it is not associated with a value.
resolve_user_name reads top-level display_name (:339), but Slack returns it at profile.display_name → always falls through to real_name/user_id.
poll_channel_messages (:177-197, limit=100) has no pagination, and the sim advances the cursor past a >100-message gap.
list_channels is a single conversations_list(types=…, limit=200) (:598).
int(Retry-After) (:159) is unguarded.
(Already fixed upstream: get_full_channel_history/get_all_thread_replies are cursor-paginated.)
Fix: capture exc outside the loop; read profile.display_name; paginate the pollers and the channel list; guard Retry-After.
PR V8 — GrantBot + FOA regex + funding detection (small-medium)
-
COR-26 (remainder) — the selection-failure fallback still posts unvetted FOAs (grantbot.py:288-289 returns every key on any exception); a dict element in the LLM's JSON hits num in all_opps → TypeError: unhashable type, uncaught (:479); GrantBot falls back to SuBot's token (:534-537); _load_researcher_profiles (:48) and _build_search_queries (:98) are ~83 dead lines with no callers. (Already fixed: two-phase _claim_foa/_release_foa.) Fix: hard-fail selection; validate the drafted channel; drop the dead helpers.
-
COR-27 — foa_cache.py:19-21 forces a -[A-Z]{2,4}- segment. Re-tested live 2026-07-30:
| input |
foa_cache.FOA_PATTERN |
funding_rules._FOA_NUMBER_RE |
PAR-24-293 |
✗ |
✓ |
PA-24-293 |
✗ |
✓ |
PAS-24-293 |
✗ |
✓ |
DE-FOA-0003456 |
✗ |
✗ |
RFA-AI-27-019 |
✓ |
✓ |
Two divergent patterns, neither IGNORECASE. Fix: one correct pattern accepting PA/PAR/PAS/RFA/NOT/DE-FOA with 2- and 4-digit years, used by both call sites.
-
COR-28 — the apostrophe classes in _ANNOUNCEMENT_PHRASES are ASCII-0x27-only (U+2019 absent, verified with od -c) in two phrases: \bi['']?ll (start|post|…) (funding_rules.py:23) and \bi'?m (going|about) to (…) (:25). Reproduced 2026-07-30: "I'll spin up a dedicated thread." → filtered; "I’ll spin up a dedicated thread." (curly) → passes the filter. Same for "I'm going to start a thread." vs the curly form. Also: is_acknowledgment_only_funding_reply false-rejects short marker-less replies (soft-livelock via funding_reject_count); _TAG_RE (:154) lacks IGNORECASE (_ANNOUNCEMENT_RE already has it). Fix: fix both apostrophe classes; broaden the ack detector; add IGNORECASE to _TAG_RE.
PR V9 — External-HTTP robustness + tool counter charging (small-medium)
- COR-29 (robustness slice) — none of
orcid.py/pubmed.py/grants.py contains any retry/backoff; raise_for_status() runs before the pacing sleep (pubmed.py:84-85); Semaphore(8) (pubmed.py:73) is sized above NCBI's keyless 3/s limit (ncbi_api_key=""). Fix: add retry/backoff; sleep before raise_for_status; size the semaphore to the key/no-key limit. (The "one shared AsyncClient" and "parallelize with gather" parts are deliberately deferred to the refactor pass.)
- COR-30 —
tools.py:116/125 increment the abstract/full-text budget before the awaited fetch, so a failed retrieval still consumes budget. Fix: increment only on success.
PR V10 — Fix the broken invite import (trivial)
invite.py:235 does from src.routers.agent_page import _get_bot_token — that name does not exist anywhere in src/. Confirmed 2026-07-30: the only two occurrences in the entire tree are the import and its call site at :236; the real helper is get_any_bot_token in src/services/slack_tokens.py:66. The ImportError is swallowed by except Exception: pass (:245), so the block that resolves a web-invited delegate's Slack ID (:238-244) is a permanent no-op. Fix: call get_any_bot_token(db) and stop swallowing ImportError.
Definition of done: each PR ships a test that fails against the pre-fix code. The regex fixes should be table-driven over the cases above.
Four verified defects in the outbound integrations: the Slack client wrapper, GrantBot + the funding regexes, the ORCID/PubMed/Grants HTTP layer, and one broken import.
Line numbers current as of
origin/main@b7edcbc(2026-07-30).Suggested order: V10 (trivial, and unblocks C1's
invite.pysite in issue #22) → V7 → V9 → V8.PR V7 — Slack client correctness cluster (small)
All in
src/agent/slack_client.py:_call_with_retryraisesUnboundLocalErroron rate-limit exhaustion —except … as excunbindsexcat block exit, and the post-loopraise SlackApiError(response=exc.response)(:167) then references it. Language semantics reproduced 2026-07-30:UnboundLocalError: cannot access local variable 'exc' where it is not associated with a value.resolve_user_namereads top-leveldisplay_name(:339), but Slack returns it atprofile.display_name→ always falls through toreal_name/user_id.poll_channel_messages(:177-197,limit=100) has no pagination, and the sim advances the cursor past a >100-message gap.list_channelsis a singleconversations_list(types=…, limit=200)(:598).int(Retry-After)(:159) is unguarded.(Already fixed upstream:
get_full_channel_history/get_all_thread_repliesare cursor-paginated.)Fix: capture
excoutside the loop; readprofile.display_name; paginate the pollers and the channel list; guardRetry-After.PR V8 — GrantBot + FOA regex + funding detection (small-medium)
COR-26 (remainder) — the selection-failure fallback still posts unvetted FOAs (
grantbot.py:288-289returns every key on any exception); a dict element in the LLM's JSON hitsnum in all_opps→TypeError: unhashable type, uncaught (:479); GrantBot falls back to SuBot's token (:534-537);_load_researcher_profiles(:48) and_build_search_queries(:98) are ~83 dead lines with no callers. (Already fixed: two-phase_claim_foa/_release_foa.) Fix: hard-fail selection; validate the drafted channel; drop the dead helpers.COR-27 —
foa_cache.py:19-21forces a-[A-Z]{2,4}-segment. Re-tested live 2026-07-30:foa_cache.FOA_PATTERNfunding_rules._FOA_NUMBER_REPAR-24-293PA-24-293PAS-24-293DE-FOA-0003456RFA-AI-27-019Two divergent patterns, neither
IGNORECASE. Fix: one correct pattern accepting PA/PAR/PAS/RFA/NOT/DE-FOA with 2- and 4-digit years, used by both call sites.COR-28 — the apostrophe classes in
_ANNOUNCEMENT_PHRASESare ASCII-0x27-only (U+2019 absent, verified withod -c) in two phrases:\bi['']?ll (start|post|…)(funding_rules.py:23) and\bi'?m (going|about) to (…)(:25). Reproduced 2026-07-30:"I'll spin up a dedicated thread."→ filtered;"I’ll spin up a dedicated thread."(curly) → passes the filter. Same for"I'm going to start a thread."vs the curly form. Also:is_acknowledgment_only_funding_replyfalse-rejects short marker-less replies (soft-livelock viafunding_reject_count);_TAG_RE(:154) lacksIGNORECASE(_ANNOUNCEMENT_REalready has it). Fix: fix both apostrophe classes; broaden the ack detector; addIGNORECASEto_TAG_RE._TAG_REfix mirrors PR E5's_extract_tagged_agentfix in the issue Agent engine: turn & thread state-machine correctness (7 PRs) #20.PR V9 — External-HTTP robustness + tool counter charging (small-medium)
orcid.py/pubmed.py/grants.pycontains any retry/backoff;raise_for_status()runs before the pacingsleep(pubmed.py:84-85);Semaphore(8)(pubmed.py:73) is sized above NCBI's keyless 3/s limit (ncbi_api_key=""). Fix: add retry/backoff; sleep beforeraise_for_status; size the semaphore to the key/no-key limit. (The "one sharedAsyncClient" and "parallelize withgather" parts are deliberately deferred to the refactor pass.)tools.py:116/125increment the abstract/full-text budget before the awaited fetch, so a failed retrieval still consumes budget. Fix: increment only on success.PR V10 — Fix the broken invite import (trivial)
invite.py:235doesfrom src.routers.agent_page import _get_bot_token— that name does not exist anywhere insrc/. Confirmed 2026-07-30: the only two occurrences in the entire tree are the import and its call site at:236; the real helper isget_any_bot_tokeninsrc/services/slack_tokens.py:66. TheImportErroris swallowed byexcept Exception: pass(:245), so the block that resolves a web-invited delegate's Slack ID (:238-244) is a permanent no-op. Fix: callget_any_bot_token(db)and stop swallowingImportError.Definition of done: each PR ships a test that fails against the pre-fix code. The regex fixes should be table-driven over the cases above.