Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 119 additions & 16 deletions examples/kb_diag_agent.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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: |-
Expand All @@ -74,7 +177,7 @@ agents:
这是 read_resource 之前的可选防护步骤,不是主要检索工具:
- URI 来自用户口述、历史陈旧结果或手工选择的目录条目时,先用本工具
确认存在再读取,避免 read_resource 读取失败;
- URI 是 list_resources 或检索工具刚返回的,可以跳过本工具直接
- URI 是 search_kb 或 list_resources 刚返回的,可以跳过本工具直接
read_resource。

支持的 URI 形式与 read_resource 一致:
Expand All @@ -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 留空)调用,
Expand All @@ -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: |-
Expand Down
11 changes: 9 additions & 2 deletions src/wolfharness/agents/native_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/wolfharness/capabilities/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions src/wolfharness/capabilities/mcp_server_cap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----

Expand Down Expand Up @@ -307,13 +310,33 @@ 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
raise RuntimeError(
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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Loading
Loading