mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
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:
co-authored by
multica-agent
parent
45361d2bdb
commit
2b3770ed26
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user