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:
TyperBody
2026-09-28 01:56:00 +08:00
parent 9ecb469d13
commit 7eeb0393a6
13 changed files with 309 additions and 69 deletions
@@ -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':