feat(api-tools): progressive disclosure for workspace operations in LocalAgent (LANG-233)

- Implement progressive disclosure for workspace operations in LocalAgent runner
- Support direct mode (<=8 operations) and progressive meta-tools mode (>8 operations)
- Implement workspace_search, workspace_describe, and workspace_call
- Inject domain overview and progressive guidance into system message
- Handle name mapping, length limit, conflict resolution with host tools
- Support truncation hint for large outputs (>20000 chars)
- Fallback to api.workspace.list() when ctx.resources.workspace_operations is empty
- Add comprehensive unit and end-to-end runner execution tests

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-11-01 07:29:16 +00:00
co-authored by multica-agent
parent 45361d2bdb
commit 2b3770ed26
7 changed files with 1242 additions and 1 deletions
@@ -205,10 +205,12 @@ class LangBotToolExecutor:
api: RunnerAPIProxy,
allowed_tools: set[str],
max_result_chars: int = DEFAULT_MAX_TOOL_RESULT_CHARS,
workspace_manager: typing.Any = None,
):
self.api = api
self.allowed_tools = allowed_tools
self.max_result_chars = max_result_chars
self.workspace_manager = workspace_manager
self.local_tools = {}
def prepare(self, request: ToolCallRequest) -> PreparedToolCall:
@@ -244,6 +246,20 @@ class LangBotToolExecutor:
if prepared.error is not None:
return self.finalize(prepared, result=None, error=prepared.error)
if self.workspace_manager is not None and self.workspace_manager.is_workspace_tool(prepared.request.name):
try:
result = await self.workspace_manager.execute(
api=self.api,
tool_name=prepared.request.name,
parameters=prepared.parameters,
max_result_chars=self.max_result_chars,
)
return self.finalize(prepared, result=result, error=None)
except Exception as e:
if is_deadline_exceeded_error(e):
raise
return self.finalize(prepared, result=None, error=f"Workspace tool execution failed: {e}")
try:
if prepared.request.name in self.local_tools:
result = await self.local_tools[prepared.request.name](**prepared.parameters)
+17
View File
@@ -8,6 +8,7 @@ DEFAULT_MAX_TOOL_ITERATIONS = 100
DEFAULT_MAX_TOOL_RESULT_CHARS = 20000
DEFAULT_RUN_TIMEOUT_SECONDS = 300
DEFAULT_TOOL_EXECUTION_MODE = "parallel"
DEFAULT_WORKSPACE_DIRECT_THRESHOLD = 8
VALID_TOOL_EXECUTION_MODES = {"parallel", "serial"}
@@ -183,3 +184,19 @@ def _positive_int(value: typing.Any, *, default: int) -> int:
if value < 1:
return default
return value
def get_workspace_direct_threshold(config: dict[str, typing.Any] | None) -> int:
"""Get the maximum number of workspace operations to expose in direct mode."""
if not isinstance(config, dict):
return DEFAULT_WORKSPACE_DIRECT_THRESHOLD
val = config.get("workspace-direct-threshold")
if val is None:
val = config.get("workspace_direct_threshold")
if val is None:
val = config.get("workspace-tools-threshold")
if val is None:
val = config.get("workspace_tools_threshold")
if isinstance(val, bool) or not isinstance(val, int) or val < 0:
return DEFAULT_WORKSPACE_DIRECT_THRESHOLD
return val
@@ -443,12 +443,14 @@ class ContextAssembler:
budget: ContextBudget | None = None,
summarizer: ContextSummarizer | None = None,
token_counter: ContextTokenCounter | None = None,
workspace_manager: typing.Any = None,
):
self.api = api
self.ctx = ctx
self.budget = budget or ContextBudget.from_context(ctx)
self.summarizer = summarizer
self.token_counter = token_counter
self.workspace_manager = workspace_manager
async def assemble(self) -> ContextAssembly:
user_text = self.ctx.input.to_text()
@@ -477,6 +479,10 @@ class ContextAssembler:
platform_tools_message = build_platform_tools_system_message(self.ctx)
if platform_tools_message is not None:
system_instructions.append(platform_tools_message.content)
if self.workspace_manager is not None:
workspace_message = self.workspace_manager.build_system_message()
if workspace_message is not None:
system_instructions.append(workspace_message.content)
if system_instructions:
# Strict chat templates only accept a system message at position zero.
guidance = "\n\n".join(system_instructions)
+15
View File
@@ -145,6 +145,21 @@ def build_platform_tools_system_message(ctx: typing.Any) -> Message | None:
)
def build_workspace_tools_system_message(
domains: list[dict[str, typing.Any]] | None,
max_chars: int = 1500,
) -> Message | None:
"""Build a system message with progressive disclosure instructions and domain overview."""
from pkg.workspace_tools import PROGRESSIVE_GUIDANCE, format_domains_overview
overview = format_domains_overview(domains or [], max_chars=max_chars)
if overview:
content = f"{PROGRESSIVE_GUIDANCE}\n\n{overview}"
else:
content = PROGRESSIVE_GUIDANCE
return Message(role="system", content=content)
def build_user_message(
user_text: str,
input_contents: list[ContentElement] | None = None,
+11 -1
View File
@@ -21,6 +21,7 @@ from pkg.config import (
)
from pkg.context_pipeline import ContextAssembler, ContextBudget, HostContextTokenCounter, LLMContextSummarizer
from pkg.model_calling import build_llm_tools
from pkg.workspace_tools import WorkspaceToolsManager
class NoAuthorizedModelError(Exception):
@@ -65,7 +66,14 @@ class AgentRunAssembler:
summarizer = LLMContextSummarizer(
self.api, model_ids[0], remove_think=remove_think, reasoning_level=reasoning_level
)
workspace_manager = await WorkspaceToolsManager.discover(self.api, self.ctx)
workspace_manager.resolve_conflicts(allowed_tools)
tools = await self._build_tools(allowed_tools)
tools.extend(workspace_manager.tools)
all_allowed_tools = allowed_tools | workspace_manager._tool_names
token_counter = HostContextTokenCounter(self.api, model_ids[0], tools, reasoning_level=reasoning_level)
context_assembly = await ContextAssembler(
@@ -74,6 +82,7 @@ class AgentRunAssembler:
budget=context_budget,
summarizer=summarizer,
token_counter=token_counter,
workspace_manager=workspace_manager,
).assemble()
return AgentRunAssembly(
@@ -82,8 +91,9 @@ class AgentRunAssembler:
tools=tools,
tool_executor=LangBotToolExecutor(
self.api,
allowed_tools,
all_allowed_tools,
max_result_chars=max_tool_result_chars,
workspace_manager=workspace_manager,
),
hooks=LangBotContextHooks(
context_budget,
@@ -0,0 +1,609 @@
"""Workspace Application API tools and progressive disclosure for LocalAgent."""
from __future__ import annotations
import collections.abc
import json
import logging
from dataclasses import dataclass
from typing import Any, Mapping
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.resource.tool import LLMTool
from pkg.config import (
DEFAULT_MAX_TOOL_RESULT_CHARS,
get_workspace_direct_threshold,
)
logger = logging.getLogger(__name__)
# Default domain metadata for LangBot Application API
DOMAINS_METADATA: dict[str, dict[str, Any]] = {
"monitoring": {
"label": {"zh_Hans": "仪表盘", "en_US": "Monitoring"},
"summary": "Inspect system metrics, service health, runtime alerts, and statistics.",
},
"stats": {
"label": {"zh_Hans": "统计", "en_US": "Statistics"},
"summary": "Basic usage statistics and activity summaries.",
},
"bot": {
"label": {"zh_Hans": "机器人", "en_US": "Bot"},
"summary": "Manage chat bots, configure message routing rules, and send outbound messages.",
},
"adapter": {
"label": {"zh_Hans": "适配器", "en_US": "Adapter"},
"summary": "Inspect IM platforms and chat network adapters available in the workspace.",
},
"agent": {
"label": {"zh_Hans": "智能体", "en_US": "Agent"},
"summary": "Create and manage autonomous AI agents, inspect execution traces, and debug agent runs.",
},
"pipeline": {
"label": {"zh_Hans": "流水线", "en_US": "Pipeline"},
"summary": "Create, configure, and manage message processing pipelines and node topologies.",
},
"knowledge": {
"label": {"zh_Hans": "知识库", "en_US": "Knowledge Base"},
"summary": "Manage knowledge bases, index documents, and retrieve relevant knowledge chunks.",
},
"plugin": {
"label": {"zh_Hans": "插件", "en_US": "Plugin"},
"summary": "Discover, install, configure, and inspect LangBot plugins and component manifests.",
},
"mcp": {
"label": {"zh_Hans": "MCP 服务", "en_US": "MCP"},
"summary": "Manage Model Context Protocol (MCP) server connections and access their resources.",
},
"skill": {
"label": {"zh_Hans": "技能", "en_US": "Skill"},
"summary": "Author, inspect, and update agent skills, specialized instructions, and prompts.",
},
"extension": {
"label": {"zh_Hans": "扩展", "en_US": "Extension"},
"summary": "List and inspect available workspace extensions and capabilities.",
},
"tool": {
"label": {"zh_Hans": "工具", "en_US": "Tool"},
"summary": "Manage workspace custom tools, functions, and external integrations.",
},
"model": {
"label": {"zh_Hans": "模型", "en_US": "Model"},
"summary": "Configure language models, fallback strategies, credentials, and parameters.",
},
"provider": {
"label": {"zh_Hans": "供应商", "en_US": "Provider"},
"summary": "Manage upstream AI model providers, endpoints, and credentials.",
},
"requester": {
"label": {"zh_Hans": "请求器", "en_US": "Requester"},
"summary": "Configure upstream HTTP clients, proxies, retry policies, and timeouts.",
},
}
PROGRESSIVE_GUIDANCE = (
"For workspace capabilities, follow progressive disclosure: first use `workspace_search` to discover operations, "
"then inspect schemas with `workspace_describe`, and finally execute with `workspace_call` "
"(先 search → describe → call)."
)
TRUNCATION_HINT = (
"Result exceeded maximum tool result characters limit and was truncated. "
"Use pagination or filtering parameters to narrow the results. "
"(结果超过长度上限已截断,请使用分页或过滤参数缩小结果。)"
)
@dataclass(frozen=True)
class NormalizedOperation:
"""Normalized representation of an authorized workspace operation."""
id: str
domain: str
label: dict[str, str]
summary: str
description: str
risk: str # 'read' | 'write' | 'dangerous'
input_schema: dict[str, Any]
output_schema: dict[str, Any]
def operation_id_to_tool_name(operation_id: str) -> str:
"""Convert operation ID to direct tool name: 'bot.list' -> 'ws_bot__list'.
Tool name starts with 'ws_', replaces '.' with '__', and length is bounded <= 64.
"""
tool_name = f"ws_{operation_id.replace('.', '__')}"
if len(tool_name) > 64:
return tool_name[:64]
return tool_name
def tool_name_to_operation_id(tool_name: str) -> str:
"""Reverse tool name back to operation ID: 'ws_bot__list' -> 'bot.list'."""
if tool_name.startswith("ws_"):
return tool_name[3:].replace("__", ".")
return tool_name.replace("__", ".")
def normalize_operation(op: Any) -> NormalizedOperation:
"""Normalize operation from dict or SDK WorkspaceOperationResource."""
if isinstance(op, Mapping):
op_id = str(op.get("id") or "")
domain = str(op.get("domain") or (op_id.split(".")[0] if "." in op_id else ""))
label = op.get("label") or {}
summary = str(op.get("summary") or "")
description = str(op.get("description") or summary or "")
risk = str(op.get("risk") or "read")
input_schema = op.get("input_schema") or {}
output_schema = op.get("output_schema") or {}
else:
op_id = str(getattr(op, "id", "") or "")
domain = str(getattr(op, "domain", None) or (op_id.split(".")[0] if "." in op_id else ""))
label = getattr(op, "label", None) or {}
summary = str(getattr(op, "summary", None) or "")
description = str(getattr(op, "description", None) or summary or "")
risk = str(getattr(op, "risk", None) or "read")
input_schema = getattr(op, "input_schema", None) or {}
output_schema = getattr(op, "output_schema", None) or {}
if not isinstance(label, dict):
label = {}
if not isinstance(input_schema, dict):
input_schema = {}
if not isinstance(output_schema, dict):
output_schema = {}
return NormalizedOperation(
id=op_id,
domain=domain,
label=label,
summary=summary,
description=description,
risk=risk,
input_schema=input_schema,
output_schema=output_schema,
)
def format_domains_overview(domain_list: list[dict[str, Any]], max_chars: int = 1500) -> str:
"""Format domain overview with a strict character budget (<= 1500 chars)."""
if not domain_list:
return ""
header = "Available workspace capability domains:\n"
lines: list[str] = []
current_len = len(header)
for d in domain_list:
name = d.get("domain", "")
label = d.get("label") or {}
zh = label.get("zh_Hans") or label.get("en_US") or name
count = d.get("count", 0)
summary = d.get("summary", "")
line = f"- {name} ({zh}, {count} ops): {summary}" if summary else f"- {name} ({zh}, {count} ops)"
if current_len + len(line) + 1 > max_chars:
prefix = f"- {name} ({zh}, {count} ops): "
available = max_chars - current_len - len(prefix) - 4
if available > 10:
lines.append(f"{prefix}{summary[:available]}...")
break
lines.append(line)
current_len += len(line) + 1
return (header + "\n".join(lines))[:max_chars]
def build_domains_from_operations(operations: list[NormalizedOperation]) -> list[dict[str, Any]]:
"""Build domain list summarizing operation counts and descriptions."""
domain_counts: dict[str, int] = {}
for op in operations:
d = op.domain or (op.id.split(".")[0] if "." in op.id else "other")
domain_counts[d] = domain_counts.get(d, 0) + 1
result = []
for d, count in sorted(domain_counts.items()):
meta = DOMAINS_METADATA.get(d, {})
label = meta.get("label", {"zh_Hans": d, "en_US": d.capitalize()})
summary = meta.get("summary", "")
result.append(
{
"domain": d,
"label": label,
"summary": summary,
"count": count,
}
)
return result
class WorkspaceToolsManager:
"""Manages workspace operations discovery, presentation (direct vs progressive),
system prompt injection, conflict resolution, and execution.
"""
def __init__(
self,
operations: list[NormalizedOperation],
domains: list[dict[str, Any]],
mode: str | None, # "direct", "progressive", or None
tools: list[LLMTool],
):
self.operations = operations
self.operations_by_id = {op.id: op for op in operations}
self.domains = domains
self.mode = mode
self.tools = tools
self._tool_names = {tool.name for tool in tools}
@classmethod
async def discover(
cls,
api: Any,
ctx: Any,
) -> WorkspaceToolsManager:
"""Discover authorized workspace operations and build the manager.
Rule 1:
优先读取本次运行资源中的 workspace_operations,没有时调用 api.workspace.list()。
列表为空、或者 SDK / Core 不支持时,不注册任何相关工具,原有行为保持不变。
"""
raw_ops: Any = None
resources = getattr(ctx, "resources", None)
if resources is not None:
raw_ops = getattr(resources, "workspace_operations", None)
domains: list[dict[str, Any]] = []
normalized_ops: list[NormalizedOperation] = []
if raw_ops:
normalized_ops = [normalize_operation(op) for op in raw_ops if op]
normalized_ops = [op for op in normalized_ops if op.id]
domains = build_domains_from_operations(normalized_ops)
else:
# Fallback to api.workspace.list()
workspace_proxy = getattr(api, "workspace", None)
if workspace_proxy is not None and callable(getattr(workspace_proxy, "list", None)):
try:
res = await workspace_proxy.list()
if isinstance(res, dict):
ops_list = res.get("operations") or []
normalized_ops = [normalize_operation(op) for op in ops_list if op]
normalized_ops = [op for op in normalized_ops if op.id]
domains = res.get("domains") or []
if not domains and normalized_ops:
domains = build_domains_from_operations(normalized_ops)
except Exception as e:
logger.warning("Failed to list workspace operations via api.workspace.list(): %s", e)
normalized_ops = []
domains = []
if not normalized_ops:
return cls(operations=[], domains=[], mode=None, tools=[])
config = getattr(ctx, "config", None) if isinstance(getattr(ctx, "config", None), dict) else {}
threshold = get_workspace_direct_threshold(config)
if len(normalized_ops) <= threshold:
mode = "direct"
tools = cls._build_direct_tools(normalized_ops)
else:
mode = "progressive"
tools = cls._build_progressive_tools()
return cls(
operations=normalized_ops,
domains=domains,
mode=mode,
tools=tools,
)
@staticmethod
def _build_direct_tools(operations: list[NormalizedOperation]) -> list[LLMTool]:
"""Build direct tools: ws_<op_id with . replaced by __>."""
async def _placeholder(**kwargs: Any) -> Any:
return kwargs
tools: list[LLMTool] = []
for op in operations:
tool_name = operation_id_to_tool_name(op.id)
desc = op.description or op.summary or f"Workspace operation {op.id}"
params = (
op.input_schema
if isinstance(op.input_schema, dict) and op.input_schema
else {
"type": "object",
"properties": {},
}
)
tools.append(
LLMTool(
name=tool_name,
human_desc=desc,
description=desc,
parameters=params,
func=_placeholder,
)
)
return tools
@staticmethod
def _build_progressive_tools() -> list[LLMTool]:
"""Build progressive disclosure meta tools: workspace_search, workspace_describe, workspace_call."""
async def _placeholder(**kwargs: Any) -> Any:
return kwargs
search_tool = LLMTool(
name="workspace_search",
human_desc="Search authorized workspace operations by keyword or domain filter",
description=(
"Search authorized workspace operations by keyword or domain filter. "
"Returns a list of matching operations with id, summary, and risk level."
),
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"Optional keyword to match against operation ID, summary, description, domain, or label."
),
},
"domain": {
"type": "string",
"description": (
"Optional domain filter (e.g. 'bot', 'pipeline', 'monitoring', 'knowledge', 'model')."
),
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (at most 10, default 10).",
"default": 10,
"minimum": 1,
"maximum": 10,
},
},
},
func=_placeholder,
)
describe_tool = LLMTool(
name="workspace_describe",
human_desc="Get schema and documentation for an authorized workspace operation",
description="Get documentation, risk level, and JSON input schema for an authorized workspace operation.",
parameters={
"type": "object",
"properties": {
"operation_id": {
"type": "string",
"description": "Exact operation ID to inspect.",
},
},
"required": ["operation_id"],
},
func=_placeholder,
)
call_tool = LLMTool(
name="workspace_call",
human_desc="Execute an authorized workspace operation",
description="Execute an authorized workspace operation with input arguments matching its schema.",
parameters={
"type": "object",
"properties": {
"operation_id": {
"type": "string",
"description": "Exact operation ID to execute.",
},
"arguments": {
"type": "object",
"description": "Input arguments matching the operation schema.",
"default": {},
},
},
"required": ["operation_id"],
},
func=_placeholder,
)
return [search_tool, describe_tool, call_tool]
def resolve_conflicts(self, existing_tool_names: collections.abc.Set[str]) -> None:
"""Resolve conflicts with host tools, platform tools, or MCP tools.
Rule: 工具名不能和宿主工具、平台工具、MCP 工具冲突,冲突时以原有工具为准,并记录 warning。
"""
filtered_tools: list[LLMTool] = []
for tool in self.tools:
if tool.name in existing_tool_names:
logger.warning(
"Workspace tool '%s' conflicts with existing tool; keeping existing tool",
tool.name,
)
else:
filtered_tools.append(tool)
self.tools = filtered_tools
self._tool_names = {t.name for t in filtered_tools}
def build_system_message(self) -> Message | None:
"""Build system prompt message for progressive mode.
Rule: 渐进模式下,把领域概览(来自 domains,≤ 1500 字符)和“先 search → describe → call”
的使用说明加进 LocalAgent 的系统提示。
"""
if self.mode != "progressive" or not self.operations:
return None
overview = format_domains_overview(self.domains, max_chars=1500)
if overview:
content = f"{PROGRESSIVE_GUIDANCE}\n\n{overview}"
else:
content = PROGRESSIVE_GUIDANCE
return Message(role="system", content=content)
def is_workspace_tool(self, tool_name: str) -> bool:
"""Check if tool name is handled by this workspace manager."""
return tool_name in self._tool_names
def execute_search(
self,
query: str | None = None,
domain: str | None = None,
limit: int = 10,
**kwargs: Any,
) -> list[dict[str, Any]]:
"""Search operations matching query and/or domain filter."""
ops = list(self.operations)
if domain:
d_lower = str(domain).strip().lower()
ops = [op for op in ops if op.domain.lower() == d_lower]
if query:
q = str(query).strip().lower()
def _matches(op: NormalizedOperation) -> bool:
if q in op.id.lower():
return True
if op.summary and q in op.summary.lower():
return True
if op.description and q in op.description.lower():
return True
if op.domain and q in op.domain.lower():
return True
if op.label:
for val in op.label.values():
if q in str(val).lower():
return True
return False
ops = [op for op in ops if _matches(op)]
try:
lim = max(1, min(int(limit), 10))
except (ValueError, TypeError):
lim = 10
return [{"id": op.id, "summary": op.summary, "risk": op.risk} for op in ops[:lim]]
def execute_describe(
self,
operation_id: str,
**kwargs: Any,
) -> dict[str, Any]:
"""Describe operation documentation, risk, and input schema."""
op = self.operations_by_id.get(operation_id)
if op is None:
return {"error": f"Operation '{operation_id}' not found or not authorized"}
return {
"id": op.id,
"description": op.description,
"risk": op.risk,
"input_schema": op.input_schema,
}
async def execute(
self,
api: Any,
tool_name: str,
parameters: dict[str, Any],
max_result_chars: int = DEFAULT_MAX_TOOL_RESULT_CHARS,
) -> Any:
"""Execute a workspace tool call."""
if self.mode == "progressive":
if tool_name == "workspace_search":
res = self.execute_search(**parameters)
return self._truncate_if_needed(res, max_result_chars)
elif tool_name == "workspace_describe":
op_id = parameters.get("operation_id", "")
res = self.execute_describe(operation_id=op_id)
return self._truncate_if_needed(res, max_result_chars)
elif tool_name == "workspace_call":
op_id = parameters.get("operation_id", "")
raw_args = parameters.get("arguments", {})
if isinstance(raw_args, str):
try:
parsed = json.loads(raw_args)
if isinstance(parsed, dict):
raw_args = parsed
except Exception:
pass
if not isinstance(raw_args, dict):
raw_args = {k: v for k, v in parameters.items() if k != "operation_id"}
return await self._call_operation(
api=api,
operation_id=op_id,
arguments=raw_args,
max_result_chars=max_result_chars,
)
else:
return {"error": f"Unknown workspace tool '{tool_name}'"}
elif self.mode == "direct":
op_id = tool_name_to_operation_id(tool_name)
return await self._call_operation(
api=api,
operation_id=op_id,
arguments=parameters,
max_result_chars=max_result_chars,
)
else:
return {"error": f"Workspace tools not active for '{tool_name}'"}
async def _call_operation(
self,
api: Any,
operation_id: str,
arguments: dict[str, Any],
max_result_chars: int = DEFAULT_MAX_TOOL_RESULT_CHARS,
) -> dict[str, Any]:
"""Execute operation via api.workspace.call, enforce authorization and truncation."""
if operation_id not in self.operations_by_id:
return {
"ok": False,
"error": {
"code": "not_authorized",
"message": f"Operation '{operation_id}' is not authorized",
},
}
workspace_proxy = getattr(api, "workspace", None)
if workspace_proxy is None or not callable(getattr(workspace_proxy, "call", None)):
return {
"ok": False,
"error": {
"code": "unsupported",
"message": "Workspace operations not supported by host or SDK",
},
}
try:
res = await workspace_proxy.call(operation_id, **arguments)
except Exception as exc:
return {
"ok": False,
"error": {
"code": "execution_failed",
"message": str(exc),
},
}
# Rule 3: ok: false 时,把错误原样返回给模型,不重试写操作。
if isinstance(res, dict) and res.get("ok") is False:
return res
# Rule 3: 返回结果超过 LocalAgent 现有工具结果的长度上限时截断,返回中给出提示:用分页或过滤参数缩小结果。
return self._truncate_if_needed(res, max_result_chars)
@staticmethod
def _truncate_if_needed(res: Any, max_result_chars: int) -> Any:
serialized = json.dumps(res, ensure_ascii=False, default=str)
if len(serialized) > max_result_chars:
preview_budget = max(0, max_result_chars - 300)
return {
"ok": res.get("ok", True) if isinstance(res, dict) else True,
"truncated": True,
"preview": serialized[:preview_budget],
"hint": TRUNCATION_HINT,
}
return res
@@ -0,0 +1,568 @@
"""Tests for workspace Application API tools and progressive disclosure."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.resources import (
AgentResources,
ModelResource,
ToolResource,
WorkspaceOperationResource,
)
from langbot_plugin.api.entities.builtin.runner.result import (
RunnerResultType,
)
from components.runner.default import DefaultRunner
from pkg.agent_core.langbot import LangBotToolExecutor
from pkg.agent_core.types import ToolCallRequest
from pkg.config import get_workspace_direct_threshold
from pkg.workspace_tools import (
WorkspaceToolsManager,
operation_id_to_tool_name,
tool_name_to_operation_id,
)
from tests.test_runner import FakeRunnerAPIProxy, make_context
def _make_op(
op_id: str,
domain: str | None = None,
summary: str = "",
description: str = "",
risk: str = "read",
input_schema: dict[str, Any] | None = None,
) -> WorkspaceOperationResource:
d = domain or op_id.split(".")[0]
return WorkspaceOperationResource(
id=op_id,
domain=d,
label={"zh_Hans": d, "en_US": d.capitalize()},
summary=summary or f"Summary for {op_id}",
description=description or f"Description for {op_id}",
risk=risk,
input_schema=input_schema or {"type": "object", "properties": {}},
)
def _make_ctx(
workspace_ops: list[WorkspaceOperationResource] | None = None,
tools: list[ToolResource] | None = None,
config: dict[str, Any] | None = None,
models: list[ModelResource] | None = None,
) -> RunnerContext:
if models is None:
models = [ModelResource(model_id="primary-model", model_name="Primary")]
cfg = {
"model": {"primary": "primary-model", "fallbacks": []},
**(config or {}),
}
resources = AgentResources(
models=models,
tools=tools or [],
workspace_operations=workspace_ops if workspace_ops is not None else [],
)
return make_context(
run_id="run-test-1",
input_text="test question",
config=cfg,
resources=resources,
delivery_supports_streaming=False,
)
# ==============================================================================
# 1. 授权列表为空时,不注册工具
# ==============================================================================
@pytest.mark.asyncio
async def test_empty_workspace_operations_registers_no_tools():
ctx = _make_ctx(workspace_ops=[])
api = Mock()
api.workspace = Mock()
api.workspace.list = AsyncMock(return_value={"operations": [], "domains": []})
mgr = await WorkspaceToolsManager.discover(api, ctx)
assert mgr.mode is None
assert len(mgr.tools) == 0
assert len(mgr.operations) == 0
assert mgr.build_system_message() is None
# ==============================================================================
# 2. 3 个操作时为直接模式,20 个操作时为渐进模式 (及阈值可配置)
# ==============================================================================
@pytest.mark.asyncio
async def test_direct_mode_for_3_operations():
ops = [
_make_op("bot.list", summary="List bots"),
_make_op("bot.create", summary="Create bot", risk="write"),
_make_op("stats.basic", summary="Basic statistics"),
]
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
mgr = await WorkspaceToolsManager.discover(api, ctx)
assert mgr.mode == "direct"
assert len(mgr.tools) == 3
tool_names = {t.name for t in mgr.tools}
assert tool_names == {"ws_bot__list", "ws_bot__create", "ws_stats__basic"}
# Direct mode does not register progressive meta tools
assert "workspace_search" not in tool_names
assert "workspace_describe" not in tool_names
assert "workspace_call" not in tool_names
# Direct mode adds no progressive system guidance
assert mgr.build_system_message() is None
@pytest.mark.asyncio
async def test_progressive_mode_for_20_operations():
ops = [_make_op(f"domain_{i}.action_{i}") for i in range(20)]
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
mgr = await WorkspaceToolsManager.discover(api, ctx)
assert mgr.mode == "progressive"
assert len(mgr.tools) == 3
tool_names = {t.name for t in mgr.tools}
assert tool_names == {"workspace_search", "workspace_describe", "workspace_call"}
# Progressive mode injects domain overview and guidance into system prompt
msg = mgr.build_system_message()
assert msg is not None
assert msg.role == "system"
assert "先 search → describe → call" in msg.content
assert "Available workspace capability domains:" in msg.content
@pytest.mark.asyncio
async def test_configurable_threshold():
ops = [_make_op(f"bot.action_{i}") for i in range(5)]
# Default threshold is 8 -> direct mode
ctx_default = _make_ctx(workspace_ops=ops)
mgr_default = await WorkspaceToolsManager.discover(Mock(), ctx_default)
assert mgr_default.mode == "direct"
# Configured threshold = 3 -> switches to progressive mode
ctx_custom = _make_ctx(workspace_ops=ops, config={"workspace-direct-threshold": 3})
assert get_workspace_direct_threshold(ctx_custom.config) == 3
mgr_custom = await WorkspaceToolsManager.discover(Mock(), ctx_custom)
assert mgr_custom.mode == "progressive"
# ==============================================================================
# 3. 工具名可以正确还原为操作 ID
# ==============================================================================
def test_tool_name_bidirectional_mapping():
cases = [
("bot.list", "ws_bot__list"),
("stats.basic", "ws_stats__basic"),
("pipeline.extensions.update", "ws_pipeline__extensions__update"),
("monitoring.metrics.query", "ws_monitoring__metrics__query"),
("knowledge.documents.chunks.list", "ws_knowledge__documents__chunks__list"),
]
for op_id, expected_tool_name in cases:
tool_name = operation_id_to_tool_name(op_id)
assert tool_name == expected_tool_name
assert len(tool_name) <= 64
reversed_op_id = tool_name_to_operation_id(tool_name)
assert reversed_op_id == op_id
# ==============================================================================
# 4. search 和 describe 只能看到授权列表里的操作
# ==============================================================================
@pytest.mark.asyncio
async def test_search_and_describe_isolated_to_authorized():
ops = [
_make_op("bot.list", domain="bot", summary="List chat bots", risk="read"),
_make_op("bot.create", domain="bot", summary="Create a new bot", risk="write"),
_make_op("stats.basic", domain="stats", summary="Basic metrics and counts", risk="read"),
]
ctx = _make_ctx(workspace_ops=ops, config={"workspace-direct-threshold": 1}) # force progressive
mgr = await WorkspaceToolsManager.discover(Mock(), ctx)
assert mgr.mode == "progressive"
# Search by keyword in authorized operations
search_res = mgr.execute_search(query="bot")
assert len(search_res) == 2
assert {r["id"] for r in search_res} == {"bot.list", "bot.create"}
assert all(set(r.keys()) == {"id", "summary", "risk"} for r in search_res)
# Search for an unauthorized domain/operation returns empty
search_unauthorized = mgr.execute_search(query="pipeline")
assert search_unauthorized == []
# Describe authorized operation
desc_ok = mgr.execute_describe("bot.list")
assert desc_ok["id"] == "bot.list"
assert "description" in desc_ok
assert desc_ok["risk"] == "read"
assert "input_schema" in desc_ok
# Describe unauthorized operation returns error
desc_err = mgr.execute_describe("pipeline.update")
assert "error" in desc_err
assert "not found or not authorized" in desc_err["error"]
# ==============================================================================
# 5. call 能把参数正确透传给 SDK (直接模式与渐进模式)
# ==============================================================================
@pytest.mark.asyncio
async def test_direct_mode_call_passthrough():
ops = [_make_op("bot.list", input_schema={"type": "object", "properties": {"limit": {"type": "integer"}}})]
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
api.workspace = Mock()
api.workspace.call = AsyncMock(return_value={"ok": True, "result": {"items": ["b1", "b2"]}})
mgr = await WorkspaceToolsManager.discover(api, ctx)
res = await mgr.execute(api, "ws_bot__list", {"limit": 10})
assert res["ok"] is True
assert res["result"] == {"items": ["b1", "b2"]}
api.workspace.call.assert_awaited_once_with("bot.list", limit=10)
@pytest.mark.asyncio
async def test_progressive_mode_call_passthrough():
ops = [_make_op(f"op_{i}.action") for i in range(10)]
target_op = ops[0].id
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
api.workspace = Mock()
api.workspace.call = AsyncMock(return_value={"ok": True, "result": {"status": "created"}})
mgr = await WorkspaceToolsManager.discover(api, ctx)
# Calling via workspace_call with nested arguments
res = await mgr.execute(
api,
"workspace_call",
{"operation_id": target_op, "arguments": {"name": "test_bot"}},
)
assert res["ok"] is True
api.workspace.call.assert_awaited_once_with(target_op, name="test_bot")
# Calling an unauthorized operation is blocked locally
res_unauth = await mgr.execute(
api,
"workspace_call",
{"operation_id": "unauthorized.dangerous.delete", "arguments": {}},
)
assert res_unauth["ok"] is False
assert res_unauth["error"]["code"] == "not_authorized"
# ==============================================================================
# 6. 结果超长时被截断并给出提示
# ==============================================================================
@pytest.mark.asyncio
async def test_large_result_truncated_with_hint():
ops = [_make_op("stats.large")]
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
api.workspace = Mock()
# Massive payload > 20000 chars
huge_payload = {"ok": True, "data": "X" * 30000}
api.workspace.call = AsyncMock(return_value=huge_payload)
mgr = await WorkspaceToolsManager.discover(api, ctx)
res = await mgr.execute(api, "ws_stats__large", {}, max_result_chars=1000)
assert res["ok"] is True
assert res["truncated"] is True
assert "preview" in res
assert "hint" in res
assert "分页或过滤参数缩小结果" in res["hint"]
# ==============================================================================
# 7. 与原有工具同名时,以原有工具为准,并记录 warning
# ==============================================================================
@pytest.mark.asyncio
async def test_tool_name_conflict_resolution(caplog):
ops = [_make_op("bot.list")] # direct name: ws_bot__list
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
mgr = await WorkspaceToolsManager.discover(api, ctx)
assert len(mgr.tools) == 1
assert mgr.tools[0].name == "ws_bot__list"
# Suppose host already has a tool named ws_bot__list
existing_tools = {"ws_bot__list", "bash", "grep"}
with caplog.at_level("WARNING"):
mgr.resolve_conflicts(existing_tools)
# Workspace tool should be dropped
assert len(mgr.tools) == 0
assert any("conflicts with existing tool" in rec.message for rec in caplog.records)
# ==============================================================================
# 8. SDK 不支持时,自动降级为原有行为
# ==============================================================================
@pytest.mark.asyncio
async def test_sdk_unsupported_graceful_degradation():
ctx = _make_ctx(workspace_ops=None)
# API has no workspace attribute at all
api_without_workspace = Mock(spec=[])
mgr = await WorkspaceToolsManager.discover(api_without_workspace, ctx)
assert mgr.mode is None
assert len(mgr.tools) == 0
assert mgr.build_system_message() is None
# ==============================================================================
# 9. ok: false 时把错误原样返回给模型,不重试写操作
# ==============================================================================
@pytest.mark.asyncio
async def test_ok_false_returned_verbatim():
ops = [_make_op("bot.delete", risk="dangerous")]
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
api.workspace = Mock()
api.workspace.call = AsyncMock(
return_value={
"ok": False,
"error": {"code": "not_found", "message": "Bot does not exist"},
}
)
mgr = await WorkspaceToolsManager.discover(api, ctx)
res = await mgr.execute(api, "ws_bot__delete", {"uuid": "b-999"})
assert res["ok"] is False
assert res["error"]["code"] == "not_found"
assert res["error"]["message"] == "Bot does not exist"
# ==============================================================================
# 10. LangBotToolExecutor 调度 workspace tools 验证
# ==============================================================================
@pytest.mark.asyncio
async def test_tool_executor_handles_workspace_tools():
ops = [_make_op("bot.list")]
ctx = _make_ctx(workspace_ops=ops)
api = Mock()
api.workspace = Mock()
api.workspace.call = AsyncMock(return_value={"ok": True, "result": ["bot-1"]})
mgr = await WorkspaceToolsManager.discover(api, ctx)
executor = LangBotToolExecutor(
api=api,
allowed_tools={"ws_bot__list"},
max_result_chars=20000,
workspace_manager=mgr,
)
prep = executor.prepare(
ToolCallRequest(
id="call-1",
name="ws_bot__list",
arguments='{"limit": 5}',
)
)
outcome = await executor.execute(prep)
assert outcome.error is None
assert outcome.result == {"ok": True, "result": ["bot-1"]}
assert outcome.message is not None
assert outcome.message.role == "tool"
assert "bot-1" in outcome.message.content
# ==============================================================================
# 11. 端到端本地运行记录:直接模式与渐进模式
# ==============================================================================
@pytest.mark.asyncio
async def test_e2e_direct_mode_agent_run():
"""Simulated local agent run in direct mode."""
ops = [
_make_op("bot.list", summary="List bots"),
_make_op("stats.basic", summary="Basic usage stats"),
]
ctx = _make_ctx(workspace_ops=ops)
api = FakeRunnerAPIProxy(
models=[ModelResource(model_id="primary-model", model_name="Primary")],
tools=[],
)
api.get_prompt = AsyncMock(return_value=[{"role": "system", "content": "You are a test assistant."}])
api.workspace = Mock()
api.workspace.call = AsyncMock(return_value={"ok": True, "result": {"bots": [{"id": "b1", "name": "HelperBot"}]}})
# Model mock: turn 1 calls ws_bot__list; turn 2 responds with text
turn_1_msg = Message(
role="assistant",
content="I will check the bots.",
tool_calls=[
{
"id": "call-bot-list-1",
"type": "function",
"function": {"name": "ws_bot__list", "arguments": '{"limit": 5}'},
}
],
)
turn_2_msg = Message(
role="assistant",
content="There is 1 bot available: HelperBot (id: b1).",
)
call_count = 0
async def mock_invoke(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return turn_1_msg
return turn_2_msg
api.invoke_llm = AsyncMock(side_effect=mock_invoke)
if hasattr(api, "invoke_llm_with_usage"):
delattr(api, "invoke_llm_with_usage")
runner = DefaultRunner()
runner.get_run_api = lambda _c: api
results = []
async for item in runner.run(ctx):
results.append(item)
tool_started = [r for r in results if r.type == RunnerResultType.TOOL_CALL_STARTED]
tool_completed = [r for r in results if r.type == RunnerResultType.TOOL_CALL_COMPLETED]
msg_completed = [r for r in results if r.type == RunnerResultType.MESSAGE_COMPLETED]
run_completed = [r for r in results if r.type == RunnerResultType.RUN_COMPLETED]
assert len(tool_started) == 1
assert tool_started[0].data["tool_name"] == "ws_bot__list"
assert tool_started[0].data["parameters"] == {"limit": 5}
assert len(tool_completed) == 1
assert tool_completed[0].data["tool_name"] == "ws_bot__list"
assert len(msg_completed) == 1
assert "HelperBot" in msg_completed[0].data["message"]["content"]
assert len(run_completed) == 1
@pytest.mark.asyncio
async def test_e2e_progressive_mode_agent_run():
"""Simulated local agent run in progressive mode with search -> describe -> call."""
ops = [_make_op(f"op_{i}.action_{i}", summary=f"Action {i} for testing") for i in range(15)]
ctx = _make_ctx(workspace_ops=ops)
api = FakeRunnerAPIProxy(
models=[ModelResource(model_id="primary-model", model_name="Primary")],
tools=[],
)
api.get_prompt = AsyncMock(return_value=[{"role": "system", "content": "You are a test assistant."}])
api.workspace = Mock()
api.workspace.call = AsyncMock(
return_value={"ok": True, "result": {"success": True, "details": "Operation executed"}}
)
# 3-step progressive execution:
# Turn 1: workspace_search(query="action_1")
# Turn 2: workspace_describe(operation_id="op_1.action_1")
# Turn 3: workspace_call(operation_id="op_1.action_1", arguments={"param": 42})
# Turn 4: Final response
turn_1 = Message(
role="assistant",
content="Searching operations...",
tool_calls=[
{
"id": "c-1",
"type": "function",
"function": {"name": "workspace_search", "arguments": '{"query": "action_1"}'},
}
],
)
turn_2 = Message(
role="assistant",
content="Describing schema...",
tool_calls=[
{
"id": "c-2",
"type": "function",
"function": {"name": "workspace_describe", "arguments": '{"operation_id": "op_1.action_1"}'},
}
],
)
turn_3 = Message(
role="assistant",
content="Calling operation...",
tool_calls=[
{
"id": "c-3",
"type": "function",
"function": {
"name": "workspace_call",
"arguments": '{"operation_id": "op_1.action_1", "arguments": {"param": 42}}',
},
}
],
)
turn_4 = Message(
role="assistant",
content="Operation op_1.action_1 successfully completed.",
)
turns = [turn_1, turn_2, turn_3, turn_4]
turn_idx = 0
async def mock_invoke(**kwargs):
nonlocal turn_idx
idx = min(turn_idx, len(turns) - 1)
turn_idx += 1
return turns[idx]
api.invoke_llm = AsyncMock(side_effect=mock_invoke)
if hasattr(api, "invoke_llm_with_usage"):
delattr(api, "invoke_llm_with_usage")
runner = DefaultRunner()
runner.get_run_api = lambda _c: api
results = []
async for item in runner.run(ctx):
results.append(item)
tool_started = [r for r in results if r.type == RunnerResultType.TOOL_CALL_STARTED]
tool_completed = [r for r in results if r.type == RunnerResultType.TOOL_CALL_COMPLETED]
msg_completed = [r for r in results if r.type == RunnerResultType.MESSAGE_COMPLETED]
run_completed = [r for r in results if r.type == RunnerResultType.RUN_COMPLETED]
called_tool_names = [t.data["tool_name"] for t in tool_started]
assert called_tool_names == ["workspace_search", "workspace_describe", "workspace_call"]
assert len(tool_completed) == 3
assert len(msg_completed) == 1
assert "op_1.action_1 successfully completed" in msg_completed[0].data["message"]["content"]
assert len(run_completed) == 1