From a85585fa27cd62c64606bc25b9e02baf3be11c94 Mon Sep 17 00:00:00 2001
From: Million <57204720+Million-mo@users.noreply.github.com>
Date: Wed, 26 Aug 2026 20:54:00 +0800
Subject: [PATCH] feat(resource): per-agent max_text_chars config +
kb_diag_agent YAML align (#394)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
---
...max-text-chars-and-kb-diag-config-align.md | 52 +++++++
examples/kb_diag_agent.yaml | 135 +++++++++++++++---
src/wolfharness/agents/native_agent/agent.py | 11 +-
src/wolfharness/capabilities/AGENTS.md | 2 +-
.../capabilities/mcp_server_cap.py | 47 ++++++
.../capabilities/resource_capability.py | 50 +++----
.../capabilities/resource_resolver.py | 12 +-
src/wolfharness_config/nodes.py | 10 ++
.../capabilities/test_resource_capability.py | 30 +++-
.../test_resource_resolution.py | 8 +-
10 files changed, 310 insertions(+), 47 deletions(-)
create mode 100644 changelog/unreleased/2026-08-26-per-agent-max-text-chars-and-kb-diag-config-align.md
diff --git a/changelog/unreleased/2026-08-26-per-agent-max-text-chars-and-kb-diag-config-align.md b/changelog/unreleased/2026-08-26-per-agent-max-text-chars-and-kb-diag-config-align.md
new file mode 100644
index 000000000..b779fad3c
--- /dev/null
+++ b/changelog/unreleased/2026-08-26-per-agent-max-text-chars-and-kb-diag-config-align.md
@@ -0,0 +1,52 @@
+# Per-agent configurable resource read truncation + kb_diag_agent config alignment
+
+## ResourceConfig.max_text_chars
+
+`ResourceConfig` now accepts a `max_text_chars` field (default 10 000,
+minimum 100) controlling the maximum text length per `read_resource` call
+before truncation. Previously the limit was a hardcoded constant
+(``_DEFAULT_READ_TEXT_LIMIT = 10_000``); now it is configurable per agent
+via the YAML config, with a programmatic override on
+``ResourceCapability.__init__``:
+
+```yaml
+agents:
+ my_agent:
+ resources:
+ enabled: true
+ max_text_chars: 20000 # allow longer chapter reads for KB agents
+```
+
+`NativeAgent` now constructs a per-agent `ResourceCapability` instance
+with the agent's `max_text_chars` instead of sharing the pool-level
+instance, so different agents can have different truncation limits.
+
+## kb_diag_agent.yaml aligned with live server
+
+The example config is updated to match the knowledge_diag MCP server's
+actual tool surface (v3.4.4, 6 tools):
+
+- **Enabled `search_kb`** — removed from `disabled_tools`; it is the
+ primary retrieval-first discovery entry point.
+- **Added `get_doc_toc` rewrite** — structure navigation tool with
+ page-number-aware TOC.
+- **Added `read_chapter_page` rewrite** — page-based reading tool
+ (`start_page` + `offset`), the server's recommended primary reading path.
+- **Updated existing tool descriptions** — `read_resource`/`list_resources`
+ now reference the page-based workflow (`search_kb → get_doc_toc →
+ read_chapter_page`) as the primary path, with URI-based reading as
+ complementary.
+- **Added `search_kb` rewrite** — full param descriptions including
+ `methods` (FULL/FAST/WIKI), `equipment_model` filter, `dataset_id`.
+
+## Resource subscribe-on-read wiring
+
+`McpServerCap` now best-effort subscribes to resource URIs after a
+successful `read_resource()` call, enabling `notifications/resources/updated`
+notifications for resources the agent has read. Tracked subscriptions are
+automatically re-established after reconnect and cleaned up on disconnect.
+
+This is a no-op for servers that declare `subscribe: false` (like the
+current knowledge_diag v3.4.4) — the subscribe call fails silently and
+the read proceeds normally. When the server enables subscription support
+(FR-5), the wiring activates automatically.
diff --git a/examples/kb_diag_agent.yaml b/examples/kb_diag_agent.yaml
index 20e05760d..9ce85a68b 100644
--- a/examples/kb_diag_agent.yaml
+++ b/examples/kb_diag_agent.yaml
@@ -19,9 +19,6 @@ agents:
type: streamable-http
name: knowledge_diag
url: https://mcp.example.com/knowledge_diag/mcp
- # 去掉 search_kb 接口:若仍需保留该工具,删除下一行即可
- disabled_tools:
- - search_kb
# servers 段的键必须与该 name 一致(缺省时回退 config 的 client_id)
name: knowledge_diag
@@ -30,18 +27,123 @@ agents:
args:
servers:
knowledge_diag:
+ # ── 检索工具(retrieval-first 工作流的主入口)──────────────
+ search_kb:
+ description: |-
+ 对知识库执行语义检索,返回按相关性排序的命中结果。
+
+ 这是检索工作流的第一步(retrieval-first),典型路径:
+ search_kb(query) → 复制命中结果中的 chapter_uri 或 primary_uri →
+ get_doc_toc(doc_uri) 了解章节结构 → read_chapter_page(doc_uri, start_page) 读正文。
+
+ 何时使用:
+ - 用户提出语义问题(如"SY215C 液压系统压力标准值是多少"),
+ 且你还不知道应该读哪个文档或章节时,先用本工具检索;
+ - 已知具体文档 ID 或章节 URI 时,可跳过检索直接 get_doc_toc 或 read_resource。
+
+ 检索模式(methods 参数):
+ - FULL:多路检索 + 重排(默认,精度最高,延迟较高约 7s);
+ - FAST:快速模式(低延迟,适合简单关键词查询);
+ - WIKI:结构化参数检索 v2.0,适合查询明确的参数标准值
+ (如压力、流量、间隙等),返回 entity_type=WIKI_PAGE 的结构化参数条目。
+
+ 使用要求:
+ - query 是自然语言查询或关键词,不确定机型时可写在 query 里;
+ - 尽量通过 equipment_model 缩小检索范围,提升命中精度;
+ - 返回结果中的 chapter_uri / primary_uri / document_id 必须直接复制,
+ 供后续 get_doc_toc / read_chapter_page / read_resource 使用。
+ param_descriptions:
+ query: |-
+ 自然语言查询或关键词。例如"液压系统压力标准值"、"换向压力检查步骤"。
+ 不确定机型时可写在 query 里。
+ dataset_id: |-
+ 知识库 ID 过滤条件,支持传入多个,用英文逗号分隔。
+ 不确定时留空,让检索跨所有知识库执行。
+ lang: |-
+ 语言过滤,如 zh_CN、en_US。不传则不限语言。
+ equipment_model: |-
+ 设备型号精确过滤,如 SY215C、SY75C。尽量传入以缩小检索范围。
+ limit: |-
+ 最大返回条数,数字类型,如 5。建议 3-5 条,过多会占用上下文。
+ methods: |-
+ 检索模式:FULL(多路+重排,默认,精度最高)、
+ FAST(快速模式,低延迟)、WIKI(结构化参数检索,适合查参数标准值)。
+
+ # ── 目录工具(了解手册章节结构)────────────────────────────
+ get_doc_toc:
+ description: |-
+ 获取单个手册的章节目录树,每个目录条目带有全文页码。
+
+ 这是检索后定位章节的结构步骤,两条路径都会用到:
+ 路径 A(检索优先):search_kb(query) → get_doc_toc(doc_uri) →
+ read_chapter_page(doc_uri, start_page);
+ 路径 B(浏览优先):list_resources() → 选定知识库 →
+ list_resources(knowledge_id) → 选定文档 →
+ get_doc_toc(kb://documents/{document_id})。
+
+ 何时使用:
+ - 已通过 search_kb 获得 document_id,想了解该手册的章节结构
+ 以便按页码阅读时;
+ - 已通过 list_resources 选定文档,想获取目录树时。
+
+ 使用要求:
+ - doc_uri 格式为 kb://documents/{document_id},document_id 从
+ search_kb 或 list_resources 结果中直接复制;
+ - 目录条目包含章节标题与 page_no(全文页码),将 page_no 直接
+ 用于 read_chapter_page 的 start_page 参数。
+ param_descriptions:
+ doc_uri: |-
+ 手册文档 URI,格式 kb://documents/{document_id}。
+ document_id 从 search_kb 的结果或 list_resources 的文档列表中直接复制。
+
+ # ── 翻页读取工具(按页码范围读正文)────────────────────────
+ read_chapter_page:
+ description: |-
+ 按全文页码范围读取手册正文。
+
+ 这是读取正文的主要工具(page-based 工作流),典型路径:
+ search_kb(query) → get_doc_toc(doc_uri) →
+ read_chapter_page(doc_uri, start_page, offset)。
+
+ 何时使用:
+ - 已通过 get_doc_toc 获得章节页码,想读取该页附近的正文时;
+ - 已通过 search_kb 获得命中的 page_start,想读取该页内容时。
+
+ 使用要求:
+ - doc_uri 格式为 kb://documents/{document_id},从 search_kb 或
+ list_resources 结果中直接复制;
+ - start_page 是全文页码(1 起始),从 get_doc_toc 的目录条目或
+ search_kb 的 page_start 字段获取;
+ - offset 是读取的页数(默认 9,即一次读 9 页)。内容过长时可
+ 减小 offset;需要继续翻页时用 start_page + offset 作为新的 start_page。
+ param_descriptions:
+ doc_uri: |-
+ 手册文档 URI,格式 kb://documents/{document_id}。
+ 从 search_kb 或 list_resources 结果中直接复制。
+ start_page: |-
+ 起始页码(1 起始)。从 get_doc_toc 的目录条目 page_no 或
+ search_kb 的 page_start 字段获取。
+ offset: |-
+ 读取页数(默认 9)。一次读 start_page 到 start_page+offset-1 页。
+ 内容过长时可减小;继续翻页用 start_page+offset 作为新 start_page。
+
+ # ── URI 读取工具(按 kb:// URI 读资源)────────────────────
read_resource:
description: |-
按 URI 读取知识库资源的完整内容(章节正文、章节目录树、文本片段、表格或图片)。
- 这是检索工作流的主要跟进工具,典型路径:
- 1. 目录浏览路径:list_resources() → 选定知识库与文档 →
+ 这是 URI 寻址的读取工具(resource-based 路径),与 read_chapter_page
+ (page-based 路径)互补:
+ - 知道页码 → 用 read_chapter_page;
+ - 知道 URI(来自 search_kb / list_resources 的输出)→ 用 read_resource。
+
+ 典型路径:
+ 1. 检索路径:search_kb(query) → 复制 primary_uri 或 chapter_uri →
+ read_resource(uri);
+ 2. 目录浏览路径:list_resources() → 选定知识库与文档 →
read_resource(kb://documents/{document_id}/toc) 获取目录树 →
复制章节/图片 URI → read_resource(uri);
- 2. 检索路径:若环境中提供语义检索工具(如 search_kb),复制其结果中的
- primary_uri 或 chapter_uri → read_resource(uri);
- 3. 对来源不明的 URI(用户口述、历史陈旧结果、手工选择的目录条目),
- 可先 check_resource_exists(uri) 预检,再读取。
+ 3. 对来源不明的 URI,可先 check_resource_exists(uri) 预检再读取。
使用要求:
- uri 是唯一入参,必须直接复制上一步工具输出中的 URI
@@ -63,9 +165,10 @@ agents:
page_start / page_end 等),可据此读取相邻章节或继续翻页。
param_descriptions:
uri: |-
- 要读取的资源 URI(kb:// 协议,英文格式)。直接从 list_resources /
- check_resource_exists 或检索工具的输出中复制(如 primary_uri、
- chapter_uri、toc_uri、图片 URI),不要自行编造 ID 或改写 URI 结构。
+ 要读取的资源 URI(kb:// 协议,英文格式)。直接从 search_kb /
+ list_resources / check_resource_exists 的输出中复制
+ (如 primary_uri、chapter_uri、toc_uri、图片 URI),
+ 不要自行编造 ID 或改写 URI 结构。
check_resource_exists:
description: |-
@@ -74,7 +177,7 @@ agents:
这是 read_resource 之前的可选防护步骤,不是主要检索工具:
- URI 来自用户口述、历史陈旧结果或手工选择的目录条目时,先用本工具
确认存在再读取,避免 read_resource 读取失败;
- - URI 是 list_resources 或检索工具刚返回的,可以跳过本工具直接
+ - URI 是 search_kb 或 list_resources 刚返回的,可以跳过本工具直接
read_resource。
支持的 URI 形式与 read_resource 一致:
@@ -95,9 +198,9 @@ agents:
description: |-
列出当前环境的快照知识库及其包含的文档(按机型组织)。
- 这是尚无任何 URI 时的第一步发现工具:
+ 这是尚无任何 URI 时的第一步发现工具(浏览优先路径):
list_resources() → 选定知识库 → list_resources(knowledge_id) →
- 选定文档 → read_resource(kb://documents/{document_id}/toc)。
+ 选定文档 → get_doc_toc(kb://documents/{document_id})。
何时使用:
- 不知道环境里有哪些知识库:不带参数(或 knowledge_id 留空)调用,
@@ -108,7 +211,7 @@ agents:
返回说明:
- 知识库记录包含 id 与名称;文档记录包含 document_id
(拼成 kb://documents/{document_id} 使用)、名称、适用机型与页数;
- - 将文档的 document_id 直接用于构造 toc URI 或 read_resource,
+ - 将文档的 document_id 直接用于构造 toc URI 或 get_doc_toc,
不要编造文档 ID。
param_descriptions:
knowledge_id: |-
diff --git a/src/wolfharness/agents/native_agent/agent.py b/src/wolfharness/agents/native_agent/agent.py
index 66796b264..04cb6047d 100644
--- a/src/wolfharness/agents/native_agent/agent.py
+++ b/src/wolfharness/agents/native_agent/agent.py
@@ -1156,9 +1156,16 @@ async def get_agentlet[AgentOutputType]( # noqa: PLR0915
)
# 6. ResourceCapability — unified resource access tools.
# Per-agent opt-out via ``resources.enabled: false`` in YAML.
+ # Per-agent max_text_chars via ``resources.max_text_chars`` in YAML.
if self.config is not None and self.config.resources.enabled:
- resource_cap = pool.resource_capability
- if resource_cap is not None and resource_cap not in self._external_capabilities:
+ from wolfharness.capabilities.resource_capability import (
+ ResourceCapability,
+ )
+
+ resource_cap = ResourceCapability(
+ max_text_chars=self.config.resources.max_text_chars
+ )
+ if resource_cap not in self._external_capabilities:
tool_capabilities.append(resource_cap)
# Register per-session capabilities (MCP, SkillManagerCap)
diff --git a/src/wolfharness/capabilities/AGENTS.md b/src/wolfharness/capabilities/AGENTS.md
index b7a0fb03b..6fa7a3495 100644
--- a/src/wolfharness/capabilities/AGENTS.md
+++ b/src/wolfharness/capabilities/AGENTS.md
@@ -27,7 +27,7 @@
- **DelegationService limits exposure**: `spawn_subagent(name, prompt)` and `get_available_agents()` only. Does not expose full `AgentPool`.
- **SkillCapability injection order**: In `get_agentlet()`, skill capabilities are injected at position 5 (after MCP, deferred bridge, approval bridge, and hook capabilities).
- **Entry-point registry**: Custom capabilities discovered via `wolfharness.capabilities` entry-point group.
-- **ExtensionRegistry scope hierarchy**: `POOL → AGENT → SESSION → TURN`. Agents outlive sessions (AGENT scope keyed by `agent_name` only, no `session_id`). `clear_session()` removes SESSION and TURN entries during teardown. Factory registers config-derived caps at AGENT scope; `get_agentlet()` registers session-specific caps (MCP, SkillManagerCap, ResourceCapability) at SESSION scope.
+- **ExtensionRegistry scope hierarchy**: `POOL → AGENT → SESSION → TURN`. Agents outlive sessions (AGENT scope keyed by `agent_name` only, no `session_id`). `clear_session()` removes SESSION and TURN entries during teardown. Factory registers config-derived caps at AGENT scope; `get_agentlet()` registers session-specific caps (MCP, SkillManagerCap) at SESSION scope. `ResourceCapability` is constructed per-agent (not registered in the ExtensionRegistry) with the agent's `max_text_chars` config.
- **ScopeLevel reorder is breaking**: `Scope` field order changed — `agent_name` is now 2nd (after `level`), `session_id` is 3rd. AGENT scope queries no longer include SESSION caps.
## Anti-Patterns
diff --git a/src/wolfharness/capabilities/mcp_server_cap.py b/src/wolfharness/capabilities/mcp_server_cap.py
index 3380eaa09..06d466b39 100644
--- a/src/wolfharness/capabilities/mcp_server_cap.py
+++ b/src/wolfharness/capabilities/mcp_server_cap.py
@@ -148,6 +148,9 @@ def __init__(
self._resources_cache: list[ResourceEntry] | None = None
self._resource_templates_cache: list[ResourceTemplateEntry] | None = None
self._connect_cooldown_until: float = 0.0
+ # URIs subscribed for resources/updated notifications (subscribe-on-read).
+ # Best-effort: only populated when the server declares subscribe support.
+ self._subscribed_uris: set[str] = set()
# ---- Properties ----
@@ -307,6 +310,8 @@ async def _on_prompts_changed() -> None:
self._resources_cache = None
self._resource_templates_cache = None
self._client = client
+ # Re-subscribe to previously tracked URIs after reconnect.
+ await self._resubscribe_all(client)
return client
self._connect_cooldown_until = time.monotonic() + _CONNECT_COOLDOWN
@@ -314,6 +319,24 @@ async def _on_prompts_changed() -> None:
f"Failed to connect MCP server {self._name!r} after {_DEFAULT_MAX_RETRIES} attempts"
) from last_error
+ async def _resubscribe_all(self, client: MCPClient) -> None:
+ """Re-subscribe to tracked URIs after a reconnect.
+
+ Args:
+ client: The newly connected MCP client.
+ """
+ if not self._subscribed_uris:
+ return
+ for uri in list(self._subscribed_uris):
+ try:
+ await client.subscribe_resource(uri)
+ except Exception:
+ logger.debug(
+ "Could not re-subscribe to resource %r after reconnect",
+ uri,
+ exc_info=True,
+ )
+
# ---- AbstractCapability overrides ----
def get_toolset(self) -> Any:
@@ -582,6 +605,18 @@ async def read_resource(
except Exception:
logger.warning("Failed to read resource %r", uri, exc_info=True)
return None
+ # Best-effort subscribe for future resources/updated notifications.
+ # Servers that don't support subscribe silently reject this.
+ if uri not in self._subscribed_uris:
+ try:
+ await client.subscribe_resource(uri)
+ self._subscribed_uris.add(uri)
+ except Exception:
+ logger.debug(
+ "Could not subscribe to resource %r (server may not support subscriptions)",
+ uri,
+ exc_info=True,
+ )
return self._convert_resource_contents(uri, contents)
async def resource_exists(self, uri: str) -> bool:
@@ -852,9 +887,21 @@ async def __aexit__(
The cached client reference is cleared so a new client will be
obtained on next use.
"""
+ # Best-effort unsubscribe all tracked URIs before closing.
+ if self._client is not None and self._subscribed_uris:
+ for uri in list(self._subscribed_uris):
+ try:
+ await self._client.unsubscribe_resource(uri)
+ except Exception:
+ logger.debug(
+ "Could not unsubscribe from resource %r during cleanup",
+ uri,
+ exc_info=True,
+ )
if self._session_pool is None and self._client is not None:
await self._client.__aexit__(exc_type, exc_val, exc_tb)
self._client = None
self._resources_cache = None
self._resource_templates_cache = None
self._change_queues.clear()
+ self._subscribed_uris.clear()
diff --git a/src/wolfharness/capabilities/resource_capability.py b/src/wolfharness/capabilities/resource_capability.py
index 7d18831d9..229218c75 100644
--- a/src/wolfharness/capabilities/resource_capability.py
+++ b/src/wolfharness/capabilities/resource_capability.py
@@ -38,6 +38,7 @@
ResourceTemplateEntry,
TextResourceContent,
)
+from wolfharness.capabilities.resource_resolver import _DEFAULT_MAX_TEXT_CHARS, _truncate_text
if TYPE_CHECKING:
@@ -51,8 +52,11 @@
# Default pagination limits.
_DEFAULT_LIST_LIMIT = 50
-_DEFAULT_READ_TEXT_LIMIT = 10_000
+_DEFAULT_READ_TEXT_LIMIT = _DEFAULT_MAX_TEXT_CHARS
_MAX_LIST_LIMIT = 100
+
+# Minimum max_text_chars, mirroring ``ResourceConfig.max_text_chars`` ge=100.
+_MIN_MAX_TEXT_CHARS = 100
_CURSOR_VERSION = 2
_MAX_COMPLETION_SUGGESTIONS = 100
_MAX_BLOB_BYTES = 10 * 1024 * 1024
@@ -82,13 +86,26 @@ class ResourceCapability(AbstractCapability[AgentDepsT]):
``resource_resolver.resolve_resource_content``) for protocol consumers.
"""
- def __init__(self, *, toolset_id: str = "resource_access") -> None:
+ def __init__(
+ self,
+ *,
+ toolset_id: str = "resource_access",
+ max_text_chars: int = _DEFAULT_READ_TEXT_LIMIT,
+ ) -> None:
"""Initialize the resource capability.
Args:
toolset_id: Identifier for the produced ``FunctionToolset``.
+ max_text_chars: Maximum text characters per resource read before
+ truncation. Content exceeding this limit is truncated with a
+ guidance suffix; the tail is not retrievable via the resource
+ read path.
"""
self._toolset_id = toolset_id
+ self._max_text_chars = max_text_chars
+ if max_text_chars < _MIN_MAX_TEXT_CHARS:
+ msg = f"max_text_chars must be >= 100, got {max_text_chars}"
+ raise ValueError(msg)
@property
def name(self) -> str:
@@ -757,8 +774,8 @@ async def read_mcp_resource( # noqa: PLR0915
if isinstance(content, TextResourceContent):
original_char_count += len(content.text)
text = content.text
- if len(text) > _DEFAULT_READ_TEXT_LIMIT:
- text = text[:_DEFAULT_READ_TEXT_LIMIT]
+ if len(text) > self._max_text_chars:
+ text = _truncate_text(text, self._max_text_chars)
truncated = True
content_entry = self._content_entry_with_meta(
{
@@ -1004,7 +1021,11 @@ async def read_resource(
skill_caps = registry.get_skill_resources(scope)
content = await resolve_resource_content(
- uri, resource_caps, skill_caps, scheme_registry=registry.scheme_registry
+ uri,
+ resource_caps,
+ skill_caps,
+ max_text_chars=self._max_text_chars,
+ scheme_registry=registry.scheme_registry,
)
if content is None:
return ToolReturn(return_value=f"Resource not found: {uri}")
@@ -1203,25 +1224,6 @@ async def complete_resource_template(
return f"Completion not supported for template: {uri_template}"
- @staticmethod
- def _truncate_text(
- text: str,
- limit: int = _DEFAULT_READ_TEXT_LIMIT,
- ) -> str:
- """Truncate text content if it exceeds the limit.
-
- Args:
- text: The text to potentially truncate.
- limit: Maximum number of characters to keep.
-
- Returns:
- The original text if within limit, or a truncated version
- with a suffix indicating the total length.
- """
- if len(text) <= limit:
- return text
- return text[:limit] + f"\n\n... [truncated: {len(text)} chars total, showing first {limit}]"
-
@staticmethod
def _format_completion_result(result: CompletionResult) -> str:
"""Format a ``CompletionResult`` into a human-readable string.
diff --git a/src/wolfharness/capabilities/resource_resolver.py b/src/wolfharness/capabilities/resource_resolver.py
index 1e9d14223..58e419d90 100644
--- a/src/wolfharness/capabilities/resource_resolver.py
+++ b/src/wolfharness/capabilities/resource_resolver.py
@@ -27,6 +27,10 @@
from wolfharness.capabilities.uri_scheme_registry import UriSchemeRegistry
+# Default maximum text characters per resource read before truncation.
+_DEFAULT_MAX_TEXT_CHARS = 10_000
+
+
def _truncate_text(text: str, max_chars: int) -> str:
"""Truncate text to ``max_chars`` if needed, appending a truncation suffix.
@@ -39,7 +43,11 @@ def _truncate_text(text: str, max_chars: int) -> str:
"""
if len(text) <= max_chars:
return text
- suffix = f"\n\n... [truncated: {len(text)} chars total, showing first {max_chars}]"
+ suffix = (
+ f"\n\n... [truncated: {len(text)} chars total, showing first {max_chars}. "
+ f"Use a narrower resource URI (e.g. a chapter or chunk URI) to read "
+ f"a specific section, or a paginated read tool for full content.]"
+ )
return text[:max_chars] + suffix
@@ -215,7 +223,7 @@ async def resolve_resource_content(
resource_caps: list[ResourceAccess],
skill_caps: list[SkillResource],
*,
- max_text_chars: int = 10_000,
+ max_text_chars: int = _DEFAULT_MAX_TEXT_CHARS,
client_name: str | None = None,
scheme_registry: UriSchemeRegistry | None = None,
) -> list[UserContent] | None:
diff --git a/src/wolfharness_config/nodes.py b/src/wolfharness_config/nodes.py
index eea4edfae..ce9ecdbb1 100644
--- a/src/wolfharness_config/nodes.py
+++ b/src/wolfharness_config/nodes.py
@@ -231,6 +231,16 @@ class ResourceConfig(Schema):
)
"""When ``True``, resource access tools are attached to the agent."""
+ max_text_chars: int = Field(
+ default=10_000,
+ title="Maximum text characters per resource read",
+ ge=100,
+ )
+ """Maximum text characters returned per ``read_resource`` call before
+ truncation. Content exceeding this limit is truncated with a guidance
+ suffix; the tail is not retrievable via the resource read path.
+ Increase for knowledge-base sources with long chapters."""
+
class BaseAgentConfig(NodeConfig):
"""Base configuration for agents."""
diff --git a/tests/capabilities/test_resource_capability.py b/tests/capabilities/test_resource_capability.py
index cb19171fb..f982c004c 100644
--- a/tests/capabilities/test_resource_capability.py
+++ b/tests/capabilities/test_resource_capability.py
@@ -408,7 +408,9 @@ async def test_formal_read_truncates_text_and_omits_unsupported_or_large_blobs()
assert result.return_value.truncated is True
assert result.return_value.original_char_count == 10_001
- assert len(result.return_value.contents[0]["text"]) == 10_000
+ truncated_text = result.return_value.contents[0]["text"]
+ assert truncated_text.startswith("x" * 10_000)
+ assert "Use a narrower resource URI" in truncated_text
assert result.return_value.errors[0].code == "unsupported_mime_type"
blob_result = await cap.read_mcp_resource(
@@ -418,6 +420,32 @@ async def test_formal_read_truncates_text_and_omits_unsupported_or_large_blobs()
assert blob_result.return_value.errors[0].code == "unsupported_mime_type"
+def test_max_text_chars_validation() -> None:
+ """ResourceCapability validates ``max_text_chars >= 100`` to match ResourceConfig."""
+ with pytest.raises(ValueError, match="max_text_chars must be >= 100, got 50"):
+ ResourceCapability(max_text_chars=50)
+ cap = ResourceCapability(max_text_chars=100)
+ assert cap._max_text_chars == 100
+
+
+async def test_read_mcp_resource_uses_max_text_chars() -> None:
+ """read_mcp_resource truncates to the capability's ``max_text_chars`` with guidance suffix."""
+ provider = FakeMcpResourceProvider(
+ "server",
+ read_contents=[TextResourceContent(uri="kb:///long", text="x" * 1_000)],
+ )
+ registry = _make_registry_with_caps(provider)
+ cap = ResourceCapability(max_text_chars=500)
+ result = await cap.read_mcp_resource(
+ _make_ctx(_make_agent_context(registry)), server="server", uri="kb:///long"
+ )
+
+ assert result.return_value.truncated is True
+ truncated_text = result.return_value.contents[0]["text"]
+ assert truncated_text.startswith("x" * 500)
+ assert "Use a narrower resource URI" in truncated_text
+
+
async def test_stateless_lifecycle() -> None:
"""__aenter__ returns self, __aexit__ is a no-op."""
cap = ResourceCapability[Any]()
diff --git a/tests/servers/opencode_server/test_resource_resolution.py b/tests/servers/opencode_server/test_resource_resolution.py
index 6fb90e697..c9d480c59 100644
--- a/tests/servers/opencode_server/test_resource_resolution.py
+++ b/tests/servers/opencode_server/test_resource_resolution.py
@@ -522,9 +522,15 @@ async def test_resolve_resource_text_truncation() -> None:
assert '' in wrapped
assert "" in wrapped
# The body is the first 10_000 chars + suffix
- suffix = f"\n\n... [truncated: {len(long_text)} chars total, showing first 10000]"
+ suffix = (
+ f"\n\n... [truncated: {len(long_text)} chars total, showing first 10000. "
+ f"Use a narrower resource URI (e.g. a chapter or chunk URI) to read "
+ f"a specific section, or a paginated read tool for full content.]"
+ )
expected_body = long_text[:10_000] + suffix
assert f'\n{expected_body}\n' == wrapped
+ # The guidance suffix steers the model toward narrower URIs.
+ assert "Use a narrower resource URI" in wrapped
# =============================================================================