Skip to content

feat(resource): configurable max_text_chars for resource reads - #393

Closed
Million-mo wants to merge 2 commits into
mainfrom
feat/configurable-resource-read-truncation
Closed

feat(resource): configurable max_text_chars for resource reads#393
Million-mo wants to merge 2 commits into
mainfrom
feat/configurable-resource-read-truncation

Conversation

@Million-mo

Copy link
Copy Markdown
Collaborator

问题

ResourceCapability.read_resource 的文本截断上限 (max_text_chars=10 000) 硬编码在 resolve_resource_content() 中,超出部分尾部静默丢弃且模型无法取回。对知识库类 MCP 服务(如 knowledge_diag,章节资源可能超 10k 字符)会导致模型拿到残缺内容。

改动

  1. ResourceCapability.__init__ 新增 max_text_chars 参数(默认 10 000,向后兼容),存储为 self._max_text_chars
  2. read_resource 工具将 self._max_text_chars 透传给 resolve_resource_content()
  3. 截断后缀改进:从无解释的 [truncated: N chars total, showing first M] 改为包含指引信息,引导模型使用更细粒度的资源 URI(章节/分片)或分页读取工具获取完整内容。

验证

  • uv run ruff check
  • uv run ruff format --check
  • uv run --no-group docs mypy ✓ (no issues)
  • uv run pytest tests/capabilities/ ✓ (1434 passed, 1 skipped)

关于 A1(cursor 分页)

调查中发现 FastMCP 的 Client.list_resources() / list_resource_templates() 已内置 cursor 自动翻页(最多 250 页),AgentPool 调用的是 FastMCP Client 而非原始 SDK ClientSession,因此列表分页不存在 bug,无需修改。

ResourceCapability now accepts a max_text_chars parameter (default 10000)
controlling the maximum text length per read_resource call before
truncation. Previously hardcoded in resolve_resource_content() with the
tail silently discarded — a problem for knowledge-base sources whose
chapter resources can exceed 10k characters.

The truncation suffix now guides the model to use a narrower resource URI
or a paginated read tool for full content, instead of an unexplained cut.

Backward-compatible: ResourceCapability() with no args preserves the default.
@Million-mo

Copy link
Copy Markdown
Collaborator Author

Superseded by PR #394 which includes all changes from this PR plus per-agent max_text_chars config wiring and kb_diag_agent.yaml alignment.

@Million-mo Million-mo closed this Aug 26, 2026
Million-mo added a commit that referenced this pull request Aug 26, 2026
…align (#394)

* feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align

ResourceConfig now accepts max_text_chars (default 10000, min 100) in
agent YAML config. NativeAgent creates a per-agent ResourceCapability
with the agent's max_text_chars instead of sharing the pool-level instance.

ResourceCapability.__init__ gains max_text_chars parameter (backward
compatible). Truncation suffix improved with guidance directing the model
to use narrower URIs or paginated read tools.

kb_diag_agent.yaml aligned with live knowledge_diag server v3.4.4:
- Enabled search_kb (removed from disabled_tools)
- Added get_doc_toc and read_chapter_page tool-schema-overlap rewrites
- Added search_kb rewrite with methods (FULL/FAST/WIKI) param docs
- Updated existing tool descriptions to reference page-based workflow

Supersedes PR #393.

* feat(mcp): resource subscribe-on-read wiring in McpServerCap

Best-effort subscribe to resource URIs after successful read_resource()
calls, enabling notifications/resources/updated for resources the agent
has read. Tracked subscriptions are re-established on reconnect and
cleaned up on disconnect.

No-op for servers with subscribe:false (like knowledge_diag v3.4.4) —
subscribe fails silently, read proceeds normally. Activates automatically
when server enables subscription support.

* fix(mcp): address PR #394 review — wire max_text_chars, fix broken test, consolidate truncation

- Wire self._max_text_chars into read_mcp_resource (was hardcoded
  _DEFAULT_READ_TEXT_LIMIT) and use _truncate_text helper with guidance
  suffix
- Remove dead _truncate_text static method from ResourceCapability
  (zero callers, old suffix format)
- Add constructor validation: max_text_chars < 100 raises ValueError
- Consolidate default constant: _DEFAULT_MAX_TEXT_CHARS in
  resource_resolver.py, aliased in resource_capability.py
- Fix broken test assertion in test_resource_resolution.py to match
  new guidance suffix format
- Add tests: max_text_chars validation, read_mcp_resource truncation
  with per-agent limit, suffix guidance text
- Correct changelog: limit was previously hardcoded, not a pre-existing
  constructor param
- Update capabilities/AGENTS.md: ResourceCapability is per-agent
  constructed, not registered at SESSION scope
Million-mo added a commit that referenced this pull request Aug 28, 2026
…align (#400)

* fix(opencode): eliminate EventBus loopback causing duplicate SSE projections (#391)

* fix(opencode): eliminate EventBus loopback causing duplicate SSE projections

The OpenCodeEventBridge republished protocol projections (MessageUpdatedEvent,
PartUpdatedEvent, etc.) back into the same EventBus that carries native agent
events. This created a feedback loop: native events → event bridge → broadcast
→ EventBus republish → SSE delivery, causing duplicate renders in attached
OpenCode TUI clients (issue #380).

Architecture change — direct-wire SSE:
- state.broadcast_event() now fans projections directly to per-connection SSE
  subscriber queues instead of republishing to EventBus
- global_routes._event_generator reads from state.event_subscribers queues
  (no EventBus subscription, no CustomEvent unwrapping)
- Reconnect replay via state.replay_projections() using Last-Event-ID
- Deleted event_bridge.py (the loopback republisher)

EventBus source isolation (defense-in-depth):
- EventEnvelope gains source_hint field; publish() accepts source_hint
- subscribe() accepts exclude_source param (filters both live fanout and replay)
- ProtocolEventConsumerMixin hooks: _get_subscription_replay() and
  _get_subscription_exclude_source() (defaults: replay=True, exclude=None)
- OpenCode overrides: replay=False, exclude_source={"opencode_event_bridge"}

Session consumer replay alignment:
- OpenCode session-level consumers now use replay=False (matching the global
  SSE endpoint's first-connect policy), preventing stale events from being
  redelivered on consumer startup

Testing:
- 7082 tests passed (full suite), ruff/mypy clean
- New e2e test: test_attach_existing_session_first_prompt_renders_once
- New unit tests: EventBus source_hint/exclude_source (4 tests)
- Rewritten integration tests for direct-wire SSE model

Note: A residual TUI-side duplication may still be visible in opencode attach
mode due to the TUI's local echo (createUserMessage) not matching the
server-generated message ID in the SSE event. This is tracked as an opencode
TUI bug (anomalyco/opencode#14372, #24773, #29478) with upstream fix PR #31945
still unmerged. The server-side fix in this commit eliminates the EventBus
loopback path; the remaining duplication is purely client-side.

* fix(opencode): address review — QueueFull policy, typed session-id extraction, mock alignment

Review-driven fixes on PR #391 (direct-wire SSE loopback elimination):

BLOCKER: broadcast_event now handles asyncio.QueueFull per-subscriber with
the same drop-oldest policy as EventBus._enqueue, so a stalled SSE client
can no longer abort fanout to every other subscriber. Adds structured
warning logging on overflow plus a debug fanout log (telemetry on the
delivery critical path). New regression test:
test_broadcast_event_drop_oldest_on_queue_full.

MAJOR: ServerState.extract_session_id now delegates to the typed
global_routes._extract_session_id (match-based, no getattr) instead of the
getattr probe that read info.id for MessageUpdatedEvent — buffering
message.updated under per-message keys and leaking memory. Typed variant
reads props.info.session_id as documented. Test mocks in
test_global_event.py / test_sse_compliance.py aligned with production:
deque(maxlen=100) buffers, drop-oldest overflow, typed extractor — the
queue-full behavior is no longer suppressed out of the green suite.

MINOR: replay_projections replays merged buffers in global event_id order
(monotonic SSE ids for reconnecting clients) and stops with a warning on
QueueFull instead of silently dropping a suffix.

MINOR: _get_subscription_exclude_source documented as currently inert
(its only producer was deleted with the loopback bridge; kept as
defense-in-depth, exercised by unit tests).

Nits: stale event_bridge docstrings/comment/names updated across
test_event_pipeline_e2e.py and conftest.py; corrected the pre-existing
dedup-set claim in src/wolfharness/AGENTS.md (the set is a private
ACPEventConverter field, not on SessionController); changelog trailing
newline; ADR eventbus-replay.md annotated as superseded by the
direct-wire design (PR #391).

Verified: ruff clean, mypy strict clean (686 files), 326 affected tests
pass.

* feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align (#394)

* feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align

ResourceConfig now accepts max_text_chars (default 10000, min 100) in
agent YAML config. NativeAgent creates a per-agent ResourceCapability
with the agent's max_text_chars instead of sharing the pool-level instance.

ResourceCapability.__init__ gains max_text_chars parameter (backward
compatible). Truncation suffix improved with guidance directing the model
to use narrower URIs or paginated read tools.

kb_diag_agent.yaml aligned with live knowledge_diag server v3.4.4:
- Enabled search_kb (removed from disabled_tools)
- Added get_doc_toc and read_chapter_page tool-schema-overlap rewrites
- Added search_kb rewrite with methods (FULL/FAST/WIKI) param docs
- Updated existing tool descriptions to reference page-based workflow

Supersedes PR #393.

* feat(mcp): resource subscribe-on-read wiring in McpServerCap

Best-effort subscribe to resource URIs after successful read_resource()
calls, enabling notifications/resources/updated for resources the agent
has read. Tracked subscriptions are re-established on reconnect and
cleaned up on disconnect.

No-op for servers with subscribe:false (like knowledge_diag v3.4.4) —
subscribe fails silently, read proceeds normally. Activates automatically
when server enables subscription support.

* fix(mcp): address PR #394 review — wire max_text_chars, fix broken test, consolidate truncation

- Wire self._max_text_chars into read_mcp_resource (was hardcoded
  _DEFAULT_READ_TEXT_LIMIT) and use _truncate_text helper with guidance
  suffix
- Remove dead _truncate_text static method from ResourceCapability
  (zero callers, old suffix format)
- Add constructor validation: max_text_chars < 100 raises ValueError
- Consolidate default constant: _DEFAULT_MAX_TEXT_CHARS in
  resource_resolver.py, aliased in resource_capability.py
- Fix broken test assertion in test_resource_resolution.py to match
  new guidance suffix format
- Add tests: max_text_chars validation, read_mcp_resource truncation
  with per-agent limit, suffix guidance text
- Correct changelog: limit was previously hardcoded, not a pre-existing
  constructor param
- Update capabilities/AGENTS.md: ResourceCapability is per-agent
  constructed, not registered at SESSION scope

* fix: re-raise serve() exceptions in ACPServer._start_async

The except Exception block logged but swallowed startup errors (e.g.
OSError: Address already in use), causing silent exit 0. Add raise
after log.exception to propagate to the CLI.

Fixes CI failure in test_start_async_propagates_serve_oserror.
Million-mo added a commit that referenced this pull request Aug 29, 2026
…on (#405)

* fix(opencode): eliminate EventBus loopback causing duplicate SSE projections (#391)

* fix(opencode): eliminate EventBus loopback causing duplicate SSE projections

The OpenCodeEventBridge republished protocol projections (MessageUpdatedEvent,
PartUpdatedEvent, etc.) back into the same EventBus that carries native agent
events. This created a feedback loop: native events → event bridge → broadcast
→ EventBus republish → SSE delivery, causing duplicate renders in attached
OpenCode TUI clients (issue #380).

Architecture change — direct-wire SSE:
- state.broadcast_event() now fans projections directly to per-connection SSE
  subscriber queues instead of republishing to EventBus
- global_routes._event_generator reads from state.event_subscribers queues
  (no EventBus subscription, no CustomEvent unwrapping)
- Reconnect replay via state.replay_projections() using Last-Event-ID
- Deleted event_bridge.py (the loopback republisher)

EventBus source isolation (defense-in-depth):
- EventEnvelope gains source_hint field; publish() accepts source_hint
- subscribe() accepts exclude_source param (filters both live fanout and replay)
- ProtocolEventConsumerMixin hooks: _get_subscription_replay() and
  _get_subscription_exclude_source() (defaults: replay=True, exclude=None)
- OpenCode overrides: replay=False, exclude_source={"opencode_event_bridge"}

Session consumer replay alignment:
- OpenCode session-level consumers now use replay=False (matching the global
  SSE endpoint's first-connect policy), preventing stale events from being
  redelivered on consumer startup

Testing:
- 7082 tests passed (full suite), ruff/mypy clean
- New e2e test: test_attach_existing_session_first_prompt_renders_once
- New unit tests: EventBus source_hint/exclude_source (4 tests)
- Rewritten integration tests for direct-wire SSE model

Note: A residual TUI-side duplication may still be visible in opencode attach
mode due to the TUI's local echo (createUserMessage) not matching the
server-generated message ID in the SSE event. This is tracked as an opencode
TUI bug (anomalyco/opencode#14372, #24773, #29478) with upstream fix PR #31945
still unmerged. The server-side fix in this commit eliminates the EventBus
loopback path; the remaining duplication is purely client-side.

* fix(opencode): address review — QueueFull policy, typed session-id extraction, mock alignment

Review-driven fixes on PR #391 (direct-wire SSE loopback elimination):

BLOCKER: broadcast_event now handles asyncio.QueueFull per-subscriber with
the same drop-oldest policy as EventBus._enqueue, so a stalled SSE client
can no longer abort fanout to every other subscriber. Adds structured
warning logging on overflow plus a debug fanout log (telemetry on the
delivery critical path). New regression test:
test_broadcast_event_drop_oldest_on_queue_full.

MAJOR: ServerState.extract_session_id now delegates to the typed
global_routes._extract_session_id (match-based, no getattr) instead of the
getattr probe that read info.id for MessageUpdatedEvent — buffering
message.updated under per-message keys and leaking memory. Typed variant
reads props.info.session_id as documented. Test mocks in
test_global_event.py / test_sse_compliance.py aligned with production:
deque(maxlen=100) buffers, drop-oldest overflow, typed extractor — the
queue-full behavior is no longer suppressed out of the green suite.

MINOR: replay_projections replays merged buffers in global event_id order
(monotonic SSE ids for reconnecting clients) and stops with a warning on
QueueFull instead of silently dropping a suffix.

MINOR: _get_subscription_exclude_source documented as currently inert
(its only producer was deleted with the loopback bridge; kept as
defense-in-depth, exercised by unit tests).

Nits: stale event_bridge docstrings/comment/names updated across
test_event_pipeline_e2e.py and conftest.py; corrected the pre-existing
dedup-set claim in src/wolfharness/AGENTS.md (the set is a private
ACPEventConverter field, not on SessionController); changelog trailing
newline; ADR eventbus-replay.md annotated as superseded by the
direct-wire design (PR #391).

Verified: ruff clean, mypy strict clean (686 files), 326 affected tests
pass.

* feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align (#394)

* feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align

ResourceConfig now accepts max_text_chars (default 10000, min 100) in
agent YAML config. NativeAgent creates a per-agent ResourceCapability
with the agent's max_text_chars instead of sharing the pool-level instance.

ResourceCapability.__init__ gains max_text_chars parameter (backward
compatible). Truncation suffix improved with guidance directing the model
to use narrower URIs or paginated read tools.

kb_diag_agent.yaml aligned with live knowledge_diag server v3.4.4:
- Enabled search_kb (removed from disabled_tools)
- Added get_doc_toc and read_chapter_page tool-schema-overlap rewrites
- Added search_kb rewrite with methods (FULL/FAST/WIKI) param docs
- Updated existing tool descriptions to reference page-based workflow

Supersedes PR #393.

* feat(mcp): resource subscribe-on-read wiring in McpServerCap

Best-effort subscribe to resource URIs after successful read_resource()
calls, enabling notifications/resources/updated for resources the agent
has read. Tracked subscriptions are re-established on reconnect and
cleaned up on disconnect.

No-op for servers with subscribe:false (like knowledge_diag v3.4.4) —
subscribe fails silently, read proceeds normally. Activates automatically
when server enables subscription support.

* fix(mcp): address PR #394 review — wire max_text_chars, fix broken test, consolidate truncation

- Wire self._max_text_chars into read_mcp_resource (was hardcoded
  _DEFAULT_READ_TEXT_LIMIT) and use _truncate_text helper with guidance
  suffix
- Remove dead _truncate_text static method from ResourceCapability
  (zero callers, old suffix format)
- Add constructor validation: max_text_chars < 100 raises ValueError
- Consolidate default constant: _DEFAULT_MAX_TEXT_CHARS in
  resource_resolver.py, aliased in resource_capability.py
- Fix broken test assertion in test_resource_resolution.py to match
  new guidance suffix format
- Add tests: max_text_chars validation, read_mcp_resource truncation
  with per-agent limit, suffix guidance text
- Correct changelog: limit was previously hardcoded, not a pre-existing
  constructor param
- Update capabilities/AGENTS.md: ResourceCapability is per-agent
  constructed, not registered at SESSION scope

* ci: retrigger CI after transient setup-uv fetch failure
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.

1 participant