Skip to content

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

Merged
Million-mo merged 3 commits into
refactor/382-framework-onlyfrom
refactor/394-max-text-chars
Aug 28, 2026
Merged

feat(resource): per-agent max_text_chars config + kb_diag_agent YAML align#400
Million-mo merged 3 commits into
refactor/382-framework-onlyfrom
refactor/394-max-text-chars

Conversation

@Million-mo

Copy link
Copy Markdown
Collaborator

Supersedes #394 (reverted during main rollback in PR #397 review).

Original PR #394 was merged but got caught in the force-push rollback. This PR re-submits the framework changes.

Changes

  • Per-agent max_text_chars config for resource truncation
  • kb_diag_agent YAML configuration alignment

Dependencies

Built on top of cleaned #382 + #391. Review/merge #399 first.

…ections (#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.
…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
@github-actions

Copy link
Copy Markdown

Review — PR #400 (per-agent max_text_chars + kb_diag_agent YAML align)

Reviewed the full diff vs main (commit 05a24d3bc = re-submitted #394; 3f1fcdf85 = #382; 2a5535dac = #391). Focused primarily on the #394 delta (the actual new work), with a docs sweep across the whole PR.

Verdict: request changes. The max_text_chars plumbing is solid and well-tested at the capability level, but the subscription feature ships non-functional on the primary read path and untested, and the PR diff contains out-of-scope, potentially sensitive files.


Blockers

1. Subscribe-on-read is wired to the wrong method — it never fires on the model-facing read path. src/wolfharness/capabilities/mcp_server_cap.py

  • The best-effort subscribe was added to McpServerCap.read_resource() (mcp_server_cap.py:590-619), the ResourceAccess protocol method.
  • But ResourceCapability.get_toolset() exposes only list_mcp_resources / list_mcp_resource_templates / read_mcp_resource, and ResourceCapability.read_mcp_resource calls provider.read_mcp_resource(uri) (resource_capability.py:739) → McpServerCap.read_mcp_resource (mcp_server_cap.py:517-523), which does not subscribe.
  • So the changelog claim ("enabling notifications/resources/updated for resources the agent has read") is not met — the feature only activates on the legacy read_resource helper and the OpenCode @-mention resolver.
  • Fix: move the subscribe block into read_mcp_resource (or a shared _read_and_subscribe(uri) used by both) and add a test proving the model-facing path subscribes.

2. Out-of-scope / sensitive files in the diff (from #382) must be dropped before merge.

  • WolfHarness情况说明_一页纸.md and WolfHarness项目情况说明与价值评估报告.md — internal corporate/IP-compliance reports addressed to company leadership (including statements about the repo being made private/deleted). This is sensitive internal material that should never land in a public framework repo. Verified present in the branch.
  • .opencode/opencode.json — malformed JSON fragment ({ ...,} trailing comma), a leftover from local tooling.
  • assets/logo/c036e3d9-ee52-4f2f-b419-760bcd2b89d6.png (1.0 MB) and assets/logo/dsh-TUI logo设计.png (2.5 MB) — completely unreferenced binary blobs (no mkdocs/markdown/JSON reference anywhere).

Major

3. Zero test coverage for the new subscription lifecycle. tests/capabilities/test_mcp_server_cap.py

  • The FakeMCPClient records _subscribed_uris/_unsubscribed_uris but no test asserts on them. No test exercises _resubscribe_all (reconnect with non-empty set) or unsubscribe-on-disconnect in __aexit__, nor the "server rejects subscribe → read still succeeds" no-op path. AGENTS.md requires tests for new protocol behavior.

4. No regression test for the pool→per-agent ResourceCapability switch, and no end-to-end config→agent→capability test for max_text_chars.

  • test_agent_with_resources_enabled_gets_capability never calls get_agentlet(); the get_agentlet suite builds agents with config=None so the resources.enabled branch is never reached. Nothing asserts that two agents configured with different max_text_chars get separate instances with the right limits, or that the pool instance is no longer injected.

5. Broken links in root AGENTS.md Context Loading table (pre-existing, not introduced here, but flagged per review scope):

  • docs/explanation/team-mode.md — file never existed; team-mode content is in docs/rfcs/draft/RFC-0055-dynamic-team-mode.md.
  • docs/explanation/extending-wolfharness.md — real file is docs/explanation/extending-agentpool.md.
  • Same stale path exists in mkdocs.yml:289 (extending-wolfharness.md), which will break zensical build.

Minor / nits

  • Dead guard: agent.py:1168resource_cap not in self._external_capabilities is always True on a freshly constructed instance (no __eq__/__hash__); append directly.
  • Telemetry: mcp_server_cap.py has zero logfire usage, and the new _resubscribe_all/subscribe-on-read blocks sit on critical reconnect/read paths emitting only plain logger calls. Per AGENTS.md, wrap with with logfire.span(...) or @logfire.instrument (orphan-span risk on the reconnect path).
  • Latency: await client.subscribe_resource(uri) is inlined in read_resource, adding an unconditional MCP round-trip on the first read of each URI with no capability negotiation gate. Consider gating on negotiated capabilities or firing under an active span.
  • Per-agent limit doesn't reach the OpenCode @-mention path: opencode_server/converters.py:163 calls resolve_resource_content(...) without max_text_chars, silently falling back to the 10 000 default. agent_routes.py:802-855 also still lists tools from the pool-level instance.
  • Private-name import + redundant alias: resource_capability.py:41,55 imports _truncate_text/_DEFAULT_MAX_TEXT_CHARS (private) and aliases _DEFAULT_READ_TEXT_LIMIT. Prefer public names in resource_resolver.py and drop the alias.
  • Subscribe dedup race: if uri not in self._subscribed_urisawait has no lock; concurrent first reads can double-subscribe (benign, but the claim isn't airtight).

Docs (verified accurate)

  • src/wolfharness/capabilities/AGENTS.md update matches the code (per-agent construction, not registered in ExtensionRegistry).
  • Changelog files conform to changelog/README.md; docs/adr/eventbus-replay.md (Superseded) and RFC-0055 annotations are accurate; src/wolfharness_server/AGENTS.md and src/wolfharness/AGENTS.md +1/-1 edits are correct.

Docs gaps (non-blocking)

  • docs/explanation/capabilities.md doesn't mention ResourceCapability at all — worth adding a note that it's constructed per-agent with resources.max_text_chars and deliberately not registered in the ExtensionRegistry.
  • The agent-level resources: config block (enabled + new max_text_chars) is undocumented in docs/how-to/configuration/node-types/agent.md.
  • capabilities/AGENTS.md phrasing nit: a pool-level instance is still built in pool._setup_resource_capability() (used by agent_routes.py), so "constructed per-agent" is only true for the agent toolset.

Positive notes

  • Good consolidation of truncation into a single shared helper with a model-actionable guidance suffix; the wiring of self._max_text_chars into read_mcp_resource (follow-up commit) fixes a real bug; dead _truncate_text removed cleanly.
  • max_text_chars is plumbed correctly through config (ge=100) → constructor (ValueError parity, with a test) → resolve_resource_content, and per-agent construction is the right architecture over the shared pool instance.
  • kb_diag_agent YAML rewrites are careful, retrieval-first workflow documentation with consistent URI/page workflows across tools.
  • No toolset_id collision: the pool instance isn't in the agent toolset, so the per-agent "resource_access" toolset is the only one.

Suggested path forward

  1. Fix blocker feat: add dynamic instructions from ResourceProviders for context… #1 (move subscribe into read_mcp_resource) + add the missing subscription-lifecycle tests (feat(agent): implement history processors for PydanticAI integration … #3) and a get_agentlet regression/e2e test for per-agent limits (feat: add configurable skills loading paths with YAML configuration #4).
  2. Drop the 5 out-of-scope files (blocker feat(tools): implement extended tool definitions with native Pydantic… #2) from the branch.
  3. Address the minor items (dead guard, telemetry spans, latency gating, converter threading) opportunistically.
  4. Fix the two stale AGENTS.md/mkdocs links.

Given the PR body states "Review/merge #399 first" — if #399 covers #382/#391, please also confirm the sensitive files and malformed JSON are removed there too, since they're part of this PR's diff against main.

@github-actions

Copy link
Copy Markdown

Review posted: #400 (comment)

Verdict: request changes. Reviewed the #394 delta (actual new work) plus a docs sweep across the full diff.

Blockers

  • Subscribe-on-read is wired into McpServerCap.read_resource() (mcp_server_cap.py:590) but the model-facing read_mcp_resource tool calls provider.read_mcp_resource() (resource_capability.py:739), which never subscribes — the feature is inert on the primary read path.
  • 5 out-of-scope files from feat(viking): archive full ACP traces and add MCP tool namespacing #382 must be dropped: two internal corporate/IP-compliance reports (WolfHarness情况说明*.md), a malformed .opencode/opencode.json, and two unreferenced logo PNGs.

Major — no tests for the subscription lifecycle (_resubscribe_all, unsubscribe-on-disconnect, no-op path); no regression/e2e test for the pool→per-agent ResourceCapability switch or config→capability max_text_chars flow; stale links in root AGENTS.md table (team-mode.md, extending-wolfharness.md) and mkdocs.yml:289.

Minor — dead always-True guard in agent.py:1168; mcp_server_cap.py uses plain logger on critical paths with zero logfire spans; inline-awaited subscribe adds an MCP round-trip per read; per-agent limit not threaded into the OpenCode @-mention resolver; private-name cross-module import; subscribe dedup race.

Docs — subsystem AGENTS.md/changelog/ADR/RFC updates verified accurate; suggested adding ResourceCapability to docs/explanation/capabilities.md and documenting the resources.max_text_chars config.

Delegate note: both specialist subagents cross-verified findings against the working tree; I independently confirmed the blocker wiring, the malformed JSON, and the tracked sensitive files before posting.

New%20session%20-%202026-08-28T08%3A02%3A28.879Z
opencode session  |  github run

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
Million-mo changed the base branch from main to refactor/391-eventbus-loopback August 28, 2026 08:24
Base automatically changed from refactor/391-eventbus-loopback to refactor/382-framework-only August 28, 2026 08:31
@Million-mo
Million-mo merged commit 30889c5 into refactor/382-framework-only Aug 28, 2026
12 checks passed
@Million-mo
Million-mo deleted the refactor/394-max-text-chars branch August 28, 2026 08:33
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