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 # =============================================================================