Files
LangBot/src/langbot/pkg/api/http/service/assistant_tools.py
T
TyperBody 67ff4d3054 fix(operation-trace): record uninstalls and deletes, drop assistant chatter
- Split the route rules on the HTTP method so a DELETE is classified as a
  destructive action instead of falling into the read/write bucket. Uninstalling
  a plugin or skill and deleting a knowledge base or MCP server were recorded as
  a view/update and left no readable trace.
- Add plugin_uninstall, skill_uninstall, knowledge_base_delete and mcp_delete.
- Skip the assistant's own conversation traffic: a chat session produced opaque
  create/resource rows that answered nothing.
- Localize the new actions and the assistant_conversation resource type in all locales.
- Sync the assistant tool action list and apply ruff formatting.
2026-09-28 02:06:57 +08:00

324 lines
13 KiB
Python

"""A deliberately small allowlist of management operations; no shell or arbitrary HTTP."""
import copy
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from langbot_plugin.api.entities.builtin.resource.tool import LLMTool
from ..authz import Permission, require_permission
from ..context import ExecutionContext
from .secrets import redact_secrets
class Arguments(BaseModel):
model_config = ConfigDict(extra='forbid')
class ListResources(Arguments):
kind: Literal['models', 'embedding_models', 'pipelines', 'knowledge_bases', 'knowledge_engines']
#: Canonical resource types, mirrored from the trace service action table. The
#: enum lets the model pick a value that exists instead of guessing a string
#: that matches nothing.
_RESOURCE_TYPES = (
'bot',
'adapter',
'model_provider',
'llm_model',
'pipeline',
'user',
'workspace',
'monitoring',
'webhook',
'api_key',
'workspace_settings',
'member',
'member_invitation',
'operation_log',
'plugin',
'plugin_page',
'skill',
'knowledge_base',
'mcp_server',
'runtime',
'system',
'resource',
)
class ListOperationLogs(Arguments):
"""Read the Workspace operation trace (owner / admin only)."""
#: Optional free-text filter is mapped to a substring match so the caller
#: can name a plugin, an account or a verb ("install") without knowing the
#: stored action key. Prefer this over ``action`` when unsure of the key.
search: str | None = Field(default=None, max_length=100)
#: Exact stored action key, e.g. ``plugin_install`` (see the action list in
#: the tool description). Only use it when the exact key is known.
action: str | None = Field(default=None, max_length=64)
resource_type: str | None = Field(
default=None,
max_length=64,
description='Optional resource type filter. Valid values: ' + ', '.join(_RESOURCE_TYPES) + '.',
)
actor: str | None = Field(
default=None,
max_length=64,
description='Optional actor filter: an account UUID or part of the actor name.',
)
limit: int = Field(
default=20,
ge=1,
le=50,
description='Maximum records to return. Keep it small (10-20) for a targeted answer.',
)
#: Forensics fields the settings panel needs but an answer does not; dropping
#: them keeps a page of records far below the assistant's per-result budget.
_COMPACT_CHANGE_FIELDS = 6
_COMPACT_CHANGE_CHARS = 160
def _brief(value, limit: int = _COMPACT_CHANGE_CHARS):
"""Render a diff value as a bounded string (mirrors the trace service)."""
if value is None or isinstance(value, (int, float, bool)):
return value
text = value if isinstance(value, str) else str(value)
return text if len(text) <= limit else text[: max(limit - 1, 0)] + '…'
def _compact_operation_records(result: dict) -> dict:
"""Project operation records onto the fields a "who did what" answer needs.
``query_logs`` returns the full panel payload -- hash chain, user agent,
payload details -- which is roughly a kilobyte per row. A whole page of
those overflows the assistant's per-result budget and arrives as a raw
truncated preview the model cannot read, which is why an answer can end up
claiming the data is unavailable. Only the actor, the action, the target,
the timestamp and the verification verdicts are kept here; the heavy
forensic columns stay available through the settings panel export.
"""
def record(row: dict) -> dict:
changes = [
{
'field': change.get('field'),
'before': _brief(change.get('before')),
'after': _brief(change.get('after')),
}
for change in (row.get('changes') or [])[:_COMPACT_CHANGE_FIELDS]
]
return {
'id': row.get('id'),
'created_at': row.get('created_at'),
'actor': row.get('actor_name') or row.get('actor_account_uuid'),
'actor_role': row.get('actor_role'),
'action': row.get('action'),
'action_i18n_key': row.get('action_i18n_key'),
'resource_type': row.get('resource_type'),
'resource_id': row.get('resource_id'),
'summary': row.get('summary'),
'changes': changes,
'outcome': row.get('outcome'),
'http_method': row.get('http_method'),
'route': row.get('route'),
'integrity_ok': row.get('integrity_ok'),
'chain_ok': row.get('chain_ok'),
'tampered': row.get('tampered'),
}
records = result.get('records') or []
return {
'total': result.get('total', len(records)),
'returned': len(records),
'tampered_count': result.get('tampered_count', 0),
'integrity_failed_count': result.get('integrity_failed_count', 0),
'chain_failed_count': result.get('chain_failed_count', 0),
'records': [record(row) for row in records],
}
class PipelineID(Arguments):
pipeline_uuid: UUID
class EngineID(Arguments):
plugin_id: str = Field(min_length=3, max_length=255)
class CreatePipeline(Arguments):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default='', max_length=1000)
class ConfigurePipeline(PipelineID):
model_uuid: UUID
system_prompt: str = Field(min_length=1, max_length=8000)
knowledge_base_uuids: list[UUID] = Field(max_length=8)
class CreateKnowledgeBase(CreatePipeline):
knowledge_engine_plugin_id: str = Field(min_length=3, max_length=255)
creation_settings: dict = Field(default_factory=dict)
retrieval_settings: dict = Field(default_factory=dict)
#: Each entry is ``(schema, description, writes, extra_permission)``. ``writes``
#: selects resource.manage vs resource.view; ``extra_permission`` narrows a tool
#: further (e.g. the audit surface needs ``audit.view``, which only the owner
#: and admin hold) and both are enforced identically when listing and calling.
TOOLS = {
'list_resources': (
ListResources,
'List existing Workspace resources. Discover IDs before using them.',
False,
None,
),
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False, None),
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False, None),
'list_operation_logs': (
ListOperationLogs,
'Search the Workspace operation trace (who changed or viewed what, with before/after fields). '
'Owner and admin only. Always pass a narrow filter instead of dumping everything: use "search" '
'for a substring such as a plugin name, an account or a verb (e.g. search="LangTARS" or '
'search="install"); use the exact "action" key only when known - valid keys include '
'plugin_install, plugin_uninstall, plugin_config, plugin_view, plugin_upgrade, skill_install, '
'skill_uninstall, create, update, delete, view, export, execute, debug, publish, member_invite, '
'member_role_update, member_remove, member_view, settings_update, settings_view, audit_log_view, '
'pipeline_extensions_update, knowledge_base_update, knowledge_base_delete, mcp_config, mcp_delete. '
'Combine resource_type and actor to answer "who installed X".',
False,
Permission.AUDIT_VIEW,
),
'create_pipeline': (
CreatePipeline,
'Create an unconnected Pipeline draft. Requires user confirmation.',
True,
None,
),
'configure_pipeline': (
ConfigurePipeline,
'Set the local-agent model, system prompt and complete knowledge-base binding list. Requires confirmation.',
True,
None,
),
'create_knowledge_base': (
CreateKnowledgeBase,
'Create a knowledge base using an installed engine. Read its schema first. Requires confirmation.',
True,
None,
),
}
def _required_permission(writes: bool, extra: Permission | None) -> Permission:
"""Return the permission a tool needs beyond the base resource verbs."""
if extra is not None:
return extra
return Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW
def _authorized(context, name: str) -> bool:
_schema, _description, writes, extra = TOOLS[name]
return _required_permission(writes, extra).value in context.workspace.permissions
def validate_call(context, name: str, arguments: dict) -> Arguments:
if name not in TOOLS:
raise ValueError('Unknown management tool')
schema, _, writes, extra = TOOLS[name]
require_permission(context, _required_permission(writes, extra))
return schema.model_validate(arguments)
def tool_definitions(context) -> list[LLMTool]:
return [
LLMTool(
name=name,
human_desc=description,
description=description,
parameters=schema.model_json_schema(),
func=execute_tool,
)
for name, (schema, description, _writes, _extra) in TOOLS.items()
if _authorized(context, name)
]
async def execute_tool(ap, context, name: str, arguments: dict):
args = validate_call(context, name, arguments).model_dump(mode='json')
if name == 'list_resources':
readers = {
'models': ap.llm_model_service.get_llm_models,
'embedding_models': ap.embedding_models_service.get_embedding_models,
'pipelines': ap.pipeline_service.get_pipelines,
'knowledge_bases': ap.knowledge_service.get_knowledge_bases,
'knowledge_engines': ap.knowledge_service.list_knowledge_engines,
}
resources = await readers[args['kind']](context)
if args['kind'] != 'knowledge_engines':
# Lists discover resources; get_pipeline / get_knowledge_schema supply configuration details.
fields = ('uuid', 'name', 'description', 'abilities', 'knowledge_engine_plugin_id')
resources = [{key: item[key] for key in fields if key in item} for item in resources]
return {'total': len(resources), 'items': redact_secrets(resources)}
if name == 'list_operation_logs':
# Delegates to the operation-trace service, so the assistant shows the
# same records and tamper verification as the settings panel, then
# compacts them so a full page still fits the per-result budget.
result = await ap.workspace_settings_service.query_logs(
context.workspace_uuid,
limit=args['limit'],
search=args['search'],
action=args['action'],
resource_type=args['resource_type'],
actor_account_uuid=args['actor'],
)
compact = _compact_operation_records(result)
# Echo the applied filters so the model can see why a result is empty
# and widen a filter instead of repeating an empty targeted query.
compact['query'] = {key: args[key] for key in ('search', 'action', 'resource_type', 'actor') if args.get(key)}
return compact
if name == 'get_pipeline':
return await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid'])
if name == 'get_knowledge_schema':
return {
'creation': await ap.knowledge_service.get_engine_creation_schema(context, args['plugin_id']),
'retrieval': await ap.knowledge_service.get_engine_retrieval_schema(context, args['plugin_id']),
}
if name == 'create_pipeline':
args['extensions_preferences'] = {
'enable_all_plugins': False,
'enable_all_mcp_servers': False,
'enable_all_skills': False,
'plugins': [],
'mcp_servers': [],
'skills': [],
'mcp_resources': [],
}
resource_id = await ap.pipeline_service.create_pipeline(context, args)
return {'uuid': resource_id, 'url': f'/home/pipelines?id={resource_id}', 'configured': False}
if name == 'configure_pipeline':
pipeline = await ap.pipeline_service.get_pipeline(context, args['pipeline_uuid'], include_secret=True)
if pipeline is None:
raise ValueError('Pipeline not found')
await ap.model_mgr.get_model_by_uuid(ExecutionContext.from_request(context), args['model_uuid'])
for kb_id in args['knowledge_base_uuids']:
if await ap.knowledge_service.get_knowledge_base(context, kb_id) is None:
raise ValueError('Knowledge base not found')
config = copy.deepcopy(pipeline['config'])
config['ai']['runner']['runner'] = 'local-agent'
local = config['ai']['local-agent']
local['model']['primary'] = args['model_uuid']
local['prompt'] = [{'role': 'system', 'content': args['system_prompt']}]
local['knowledge-bases'] = args['knowledge_base_uuids']
await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], {'config': config})
return {'uuid': args['pipeline_uuid'], 'url': f'/home/pipelines?id={args["pipeline_uuid"]}'}
resource_id = await ap.knowledge_service.create_knowledge_base(context, args)
return {'uuid': resource_id, 'url': f'/home/knowledge?id={resource_id}'}