mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
feat(operation-trace): collapsible retention policy and assistant trace tool
- Collapse the retention policy section by default, showing a one-line summary when collapsed. - Add the owner/admin-only list_operation_logs assistant tool backed by audit.view. - Enforce per-tool permissions on both tool listing and invocation. - Add a substring search filter (search) to query_logs and the tool so callers can ask narrow questions (plugin name, account, verb) instead of dumping the full history. - Project records to a compact shape so a page stays under the assistant result budget. - Localize the new assistant tool label and retention summary across all locales.
This commit is contained in:
@@ -25,6 +25,10 @@ Do not claim the application has been tested: this experiment has no chat-test o
|
||||
After creation/configuration show the returned resource URL so the user can open the normal editor,
|
||||
upload documents and use its existing debug chat. If an operation failed or its result is unknown,
|
||||
do not repeat a write automatically; explain the result and ask the user to inspect the resource.
|
||||
When the list_operation_logs tool is available (owner / admin only), use it to answer "who changed or
|
||||
viewed what" questions with the recorded before/after fields instead of guessing. Always query it with
|
||||
a narrow filter (search / resource_type / actor) rather than an unfiltered dump, and never repeat the
|
||||
same empty query: if a filter matched nothing, widen it once instead of re-asking.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,129 @@ 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
|
||||
|
||||
@@ -45,29 +168,55 @@ class CreateKnowledgeBase(CreatePipeline):
|
||||
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),
|
||||
'get_pipeline': (PipelineID, 'Read a Pipeline configuration with secrets redacted.', False),
|
||||
'get_knowledge_schema': (EngineID, 'Get the engine creation and retrieval configuration schemas.', False),
|
||||
'create_pipeline': (CreatePipeline, 'Create an unconnected Pipeline draft. Requires user confirmation.', True),
|
||||
'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,
|
||||
'Read the Workspace operation trace (who changed or viewed what, with before/after fields). '
|
||||
'Owner and admin only. Use it to answer "who did what" questions about this Workspace.',
|
||||
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 = TOOLS[name]
|
||||
require_permission(context, Permission.RESOURCE_MANAGE if writes else Permission.RESOURCE_VIEW)
|
||||
schema, _, writes, extra = TOOLS[name]
|
||||
require_permission(context, _required_permission(writes, extra))
|
||||
return schema.model_validate(arguments)
|
||||
|
||||
|
||||
@@ -80,8 +229,8 @@ def tool_definitions(context) -> list[LLMTool]:
|
||||
parameters=schema.model_json_schema(),
|
||||
func=execute_tool,
|
||||
)
|
||||
for name, (schema, description, writes) in TOOLS.items()
|
||||
if not writes or Permission.RESOURCE_MANAGE in context.workspace.permissions
|
||||
for name, (schema, description, _writes, _extra) in TOOLS.items()
|
||||
if _authorized(context, name)
|
||||
]
|
||||
|
||||
|
||||
@@ -101,6 +250,23 @@ async def execute_tool(ap, context, name: str, arguments: dict):
|
||||
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':
|
||||
|
||||
Reference in New Issue
Block a user