Skip to content

Fix(agent / skills) issue 307 content filter resilience#308

Merged
warren618 merged 8 commits into
HKUDS:mainfrom
shadowinlife:feat/content-filter-resilience
Jun 27, 2026
Merged

Fix(agent / skills) issue 307 content filter resilience#308
warren618 merged 8 commits into
HKUDS:mainfrom
shadowinlife:feat/content-filter-resilience

Conversation

@shadowinlife

@shadowinlife shadowinlife commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Event-driven backtests no longer fail entirely when individual news items trigger LLM content moderation (e.g., DashScope/Qwen "绿网"). Filtered items are skipped and tracked.
  • When the content-filter hit ratio exceeds a configurable threshold (default 5%), the run card warns the user to switch providers. The threshold is displayed at startup and adjustable via natural language.
  • 4-layer fix: provider detection → loop/worker graceful skip → ratio tracking → result surfacing in run card.

Why

  • When the content-filter hit ratio exceeds a configurable threshold (default 5%), the run card warns the user to switch providers. The threshold is displayed at startup and adjustable via natural language.

Changes

  • Provider: content_filter_triggered flag on LLMResponse when finish_reason == "content_filter"
  • Agent loop / Swarm worker: skip-and-continue on filter hit (trace + system message + counter)
  • Ratio tracking: surface content_filter_warnings in result dict and WorkerResult when hit ratio > threshold (default 5%)
  • Backtest engines: wire warnings through to write_run_card()
  • Preflight: display threshold at startup; set_env_override tool for runtime NL adjustment (gated by VIBE_TRADING_ENABLE_SHELL_TOOLS)
  • New: src/core/env_overrides.py, src/tools/env_override_tool.py

Current changes only work for OPEN AI API models.
TODO: support gemini and claude

Test Plan

  • Existing tests pass (pytest --ignore=agent/tests/e2e_backtest --tb=short -q)
  • New tests added (if applicable)
  • Tested manually
    • Loaded event-driven skill
    • search_symbol("159516") → resolved to 半导体设备ETF国泰 (Semiconductor Equipment ETF)
    • get_stock_news("159516.SZ") → Eastmoney returned empty payload (JSON parse error, not a content-filter block)
    • web_search("半导体设备ETF 159516.SZ news announcements 2024 2025") → succeeded, no content filter triggered
    • get_market_data → OHLCV fetched successfully
    • create_hypothesis + generate_backtest_config + scaffold_signal_engine → all OK
    • write_file("code/signal_engine.py") → sentiment-based signal engine written
    • backtest → completed in 7.1s, produced run_card.json with full metrics
    • Post-backtest attribution (Layer 1 + Layer 4 Monte Carlo) → completed

Checklist

  • No changes to protected areas (src/agent/, src/session/, src/providers/) without prior discussion
  • No hardcoded values (API keys, file paths, magic numbers)
  • Code follows CONTRIBUTING.md guidelines
  • Documentation updated

Closes #307

shadowinlife and others added 4 commits June 25, 2026 17:15
When LLM content moderation (e.g. DashScope/Qwen 绿网) blocks individual
news items during event-driven analysis, the agent now skips the filtered
item and continues instead of aborting the entire run.

4-layer fix:
- Provider: content_filter_triggered flag on LLMResponse (chat.py)
- Agent loop: skip-and-continue with trace entry + system message (loop.py)
- Swarm worker: same pattern with event emission (worker.py)
- Result surfacing: ratio tracking with 5% threshold warning in run_card

Additional:
- Preflight displays current CONTENT_FILTER_WARNING_THRESHOLD
- .env.example documents the new env var
- set_env_override tool for runtime NL adjustment (immediate + persistent)
- System prompt updated with Runtime Configuration section

Trigger scenario: vibe-trading run -p "Run an event-driven backtest on
159516.SZ using recent news and announcements for sentiment scoring"

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 196/196
AI-Contributed/UT: 373/373
…nt tool

Wave 2 implementation:
- Agent loop: track content_filter ratio, surface warnings in result dict
  when ratio > 5% threshold (configurable via CONTENT_FILTER_WARNING_THRESHOLD)
- Swarm worker: same ratio tracking, add content_filter_warnings to WorkerResult
- Backtest engines: wire content_filter_warnings from config to run_card
- Preflight: display current CONTENT_FILTER_WARNING_THRESHOLD at startup
- .env.example: document new env var with default 0.05 (5%)
- env_overrides.py: set_env_override() for immediate + persistent adjustment
- env_override_tool.py: register as agent tool, gated by VIBE_TRADING_ENABLE_SHELL_TOOLS
- context.py: add Runtime Configuration section to system prompt

Tests: 4 new test files, 28 new test cases covering ratio tracking,
run_card wiring, preflight display, and env override behavior.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 6/6
AI-Contributed/UT: 419/419
Simulates a full agent run with interleaved content_filter hits:
- 2/5 iterations trigger content_filter (40% > 5% threshold)
- Verifies: run succeeds, warnings surfaced, trace entries correct,
  warning message contains ratio and provider-switch recommendation
- Clean run test: 0/5 filtered, no warnings, no trace entries

Uses real TraceWriter (no mocking), interleaved LLM stub pattern
matching the real-world scenario of event-driven backtest on
159516.SZ where semiconductor news triggers DashScope moderation.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 0/0
AI-Contributed/UT: 220/220
… return paths

F1 plan compliance audit found that 5/8 WorkerResult return paths in
worker.py did not pass content_filter_warnings, silently dropping
accumulated warnings via the default empty list.

Fix: extract ratio calculation into _compute_content_filter_warnings()
helper and call it at every mid-loop WorkerResult construction:
- timeout (~line 449)
- token_limit (~line 468)
- LLM call failure (~line 573)
- incomplete mid-loop (~line 630)
- completed mid-loop (~line 642)

Pre-loop prompt template failure path unchanged (counter not yet init).

Also fixes ruff F841 unused variable in preflight.py:112.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/qwen3.7-max
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 58/58
AI-Contributed/UT: 0/0
@shadowinlife shadowinlife marked this pull request as draft June 25, 2026 10:08
shadowinlife and others added 2 commits June 25, 2026 18:44
Simplify the PR by removing the generic env-override tool entirely
(-176 LOC, -4 files). The preflight display and .env.example docs
already cover threshold discoverability. Also fixes Chinese text in
chat.py docstring and context.py system prompt to meet the
English-only constraint, and hardens loop.py to use getattr for
duck-typed mock LLM compatibility.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 101/101
AI-Contributed/UT: 89/89
- Extract shared content_filter.py module (DRY: threshold parsing,
  warning computation, skip message constant)
- Fix message role inconsistency: loop.py now uses 'system' (was 'user')
- Move content_filter check before has_tool_calls in loop.py (matches
  worker's defensive placement)
- Add consecutive-skip circuit breaker (10 consecutive -> early exit)
  in both loop.py and worker.py
- Guard float() env var parsing with try/except (was unguarded in
  hot paths, only preflight had the guard)
- Add content_filter mapping in openai_codex _map_finish_reason
- Update CHANGELOG.md and README.md env var table
- Add circuit breaker tests for both loop and worker
- Fix pre-existing F841 lint error in test_run_card_content_filter.py

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 254/254
AI-Contributed/UT: 27/27
@shadowinlife

Copy link
Copy Markdown
Contributor Author

Code Review Summary — Content-Filter Resilience

A 5-agent parallel review was conducted on this branch. All identified issues have been fixed and verified.

Review Process

# Review Area Agent Verdict
1 Goal & Constraint Verification Oracle ✅ PASS
2 QA Execution (tests + ruff) unspecified-high ✅ PASS
3 Code Quality Oracle ❌ FAIL → Fixed
4 Security Oracle ✅ PASS
5 Context Mining (git/codebase) unspecified-high ✅ PASS

Issues Found & Fixed

MAJOR (blocking — all fixed)

# Issue Fix
1 Unguarded float() env var parsingloop.py and worker.py had no try/except on CONTENT_FILTER_WARNING_THRESHOLD, while preflight.py did Extracted shared get_content_filter_threshold() with try/except fallback to 0.05
2 Message role inconsistencyloop.py used "role": "user", worker.py used "role": "system" for the same recovery message Changed loop.py to "role": "system"
3 DRY violation — ratio computation logic duplicated: inline in loop.py, extracted helper in worker.py Created shared agent/src/providers/content_filter.py module; both files now import compute_content_filter_warnings()
4 Check placement inconsistencyloop.py checked content_filter inside if not response.has_tool_calls:, worker.py checked before it Moved loop.py check before the tool-call branch (more defensive — a filtered response never executes tools)
5 Warning string duplicated in 3+ locations Centralized in CONTENT_FILTER_SKIP_MESSAGE constant + shared function

Non-blocking (all addressed)

# Issue Fix
6 No circuit breaker for 100% filter rate Added MAX_CONSECUTIVE_CONTENT_FILTER_SKIPS = 10 — loop and worker both early-exit after 10 consecutive blocks
7 CHANGELOG.md not updated Added [Unreleased] entry
8 README.md missing CONTENT_FILTER_WARNING_THRESHOLD docs Added to env var table
9 openai_codex provider didn't map content_filter Added "content_filter": "content_filter" in _map_finish_reason
10 Pre-existing F841 lint error (unused card var) Removed unused assignment

New Files

  • agent/src/providers/content_filter.py — shared module with compute_content_filter_warnings(), get_content_filter_threshold(), CONTENT_FILTER_SKIP_MESSAGE, MAX_CONSECUTIVE_CONTENT_FILTER_SKIPS

Verification

Check Result
Ruff lint (13 files) ✅ All checks passed
Content-filter tests (44 tests) ✅ 44 passed (42 original + 2 new circuit breaker tests)
Full regression suite ✅ 4396 passed, 1 skipped, 0 failed (125.89s)

Commit

959a548fix: address code review issues for content-filter resilience

shadowinlife and others added 2 commits June 25, 2026 19:50
…mmit

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Co-Authored-By: opencode <noreply@ai-tool.com>
AI-Contributed/Feature: 12/12
AI-Contributed/UT: 0/0
Gemini surfaces content moderation via uppercase FinishReason enum values
(SAFETY, RECITATION, BLOCKLIST, PROHIBITED_CONTENT, SPII, IMAGE_SAFETY,
IMAGE_PROHIBITED_CONTENT, IMAGE_RECITATION) instead of OpenAI's lowercase
"content_filter". Google's OpenAI-compatible endpoint passes these through
unmapped, so the existing finish_reason == "content_filter" check silently
missed every Gemini content-filter hit — the circuit breaker, skip logic,
and warning pipeline from HKUDS#307 were all inactive for the Gemini provider.

Add GEMINI_SAFETY_FINISH_REASONS and is_content_filter_triggered() to
content_filter.py; ChatLLM._parse_response now calls it instead of the bare
equality check. Case-insensitive on the Gemini side so a provider that
lowercases the value still matches.

Evidence: 7 independent GitHub sources (4 official repos — google-gemini,
langchain-ai, firebase, microsoft) confirm Gemini returns uppercase SAFETY,
not content_filter, and that client-side mapping is required.

Regression coverage: 25 new tests in test_content_filter_gemini_detection.py
covering all 8 Gemini safety reasons, OpenAI content_filter regression,
case-insensitivity, non-string inputs, and _parse_response integration.

Co-Authored-By: OpenCode <noreply@opencode.ai>
AI-Model: alibaba-cn/glm-5.2
Co-Authored-By: opencode <noreply@ai-tool.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
AI-Contributed/Feature: 36/36
AI-Contributed/UT: 83/83
@shadowinlife

Copy link
Copy Markdown
Contributor Author

补充修复:Gemini provider 的 content filter 静默漏检(commit 3f93227

问题

#307 的 content filter 修复(finish_reason == "content_filter" 检测)对 Gemini provider 静默失效——熔断器、skip 逻辑、警告管道在 Gemini 上全部不生效。

根因

Gemini 通过 OpenAI 兼容端点返回的是 Gemini 原生大写 FinishReason 枚举,而非 OpenAI 标准的 "content_filter"

Gemini 原生值 含义
SAFETY 安全策略拦截
RECITATION 版权/复述拦截
BLOCKLIST 术语黑名单
PROHIBITED_CONTENT 违禁内容
SPII 敏感个人信息
IMAGE_SAFETY / IMAGE_PROHIBITED_CONTENT / IMAGE_RECITATION 图像安全

Google 的 OpenAI 兼容端点不映射这些值到 "content_filter",而是透传原生大写枚举。现有检测只匹配 "content_filter",所以 Gemini 的每次 content filter 命中都漏检。

证据(7 个独立 GitHub 来源,含 4 个官方仓库)

  • google-gemini 官方仓库测试 fixture 用 "finish_reason": "SAFETY"
  • langchain-ai/langchain-google 官方集成测试用 "finish_reason": "SAFETY"
  • firebase/flutterfire 官方解析测试用 "finishReason": "SAFETY"
  • microsoft/semantic-kernel 官方 SSE 解析测试含真实 finishReason: "SAFETY" chunk
  • bytedance/deer-flow 为 Gemini 单独写 GeminiSafetyDetector(与 OpenAI 的 content_filter 检测器并存)
  • BerriAI/litellm 维护显式映射表 SAFETY → content_filter
  • traceloop/openllmetry 测试断言 extract_finish_reasons(["STOP","SAFETY"]) == ["stop","content_filter"]

修复

  1. content_filter.py 新增 GEMINI_SAFETY_FINISH_REASONS 集合 + is_content_filter_triggered() helper,集中"什么算 content filter"的知识(识别 OpenAI content_filter + Gemini 8 种安全枚举,大小写不敏感)
  2. chat.py _parse_responsefinish_reason == "content_filter" 改为调用 is_content_filter_triggered(finish_reason)
  3. 设计上为未来 Claude stop_reason="refusal" 映射预留扩展点(仅需在 helper 加一行)

测试

新增 test_content_filter_gemini_detection.py(25 个测试):8 个 Gemini 安全枚举参数化覆盖 + OpenAI content_filter 回归 + 大小写不敏感 / 非字符串输入 / _parse_response 集成。

全量 content filter 套件 47/47 通过(含 E2E),0 回归。

Claude 说明

项目当前无 Claude/Anthropic provider,暂不适用。但调研确认 Claude 有 stop_reason="refusal"(对标 OpenAI content_filter,仅 Fable 5 产生)+ stop_details 分类(cyber/bio/frontier_llm/reasoning_extraction/military_weapons),未来接入时需映射。

@shadowinlife shadowinlife marked this pull request as ready for review June 25, 2026 12:35
@shadowinlife shadowinlife changed the title Fix issue 307 content filter resilience Fix(agent / skills) issue 307 content filter resilience Jun 26, 2026
@shadowinlife shadowinlife changed the title Fix(agent / skills) issue 307 content filter resilience Fix(agent / skills) issue 307 content filter resilience #307 Jun 26, 2026
@shadowinlife shadowinlife changed the title Fix(agent / skills) issue 307 content filter resilience #307 Fix(agent / skills) issue 307 content filter resilience Jun 26, 2026
@warren618

Copy link
Copy Markdown
Collaborator

Thanks for putting this together and for linking it back to #307. We’ve seen the PR and will review it as soon as we can.

Because this touches provider/agent-loop behavior, we’ll pay particular attention to deterministic tests and making sure the content-filter path degrades gracefully without hiding unrelated provider failures.

@warren618 warren618 merged commit d156df1 into HKUDS:main Jun 27, 2026
1 check passed
@warren618

Copy link
Copy Markdown
Collaborator

Merged via squash in d156df1. Thanks @shadowinlife — this closes #307 and gives the agent/swarm paths a safer skip-and-warn flow for content-filtered LLM responses, including Gemini safety finish-reason detection.

We’ll keep watching provider-specific moderation behavior in follow-ups.

@shadowinlife shadowinlife deleted the feat/content-filter-resilience branch July 13, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Event-driven backtest fails entirely when a single news item triggers LLM content moderation filter (DashScope/Qwen)

2 participants