diff --git a/src/langbot/pkg/api/http/service/assistant.py b/src/langbot/pkg/api/http/service/assistant.py index 19701b9c8..74e7073ae 100644 --- a/src/langbot/pkg/api/http/service/assistant.py +++ b/src/langbot/pkg/api/http/service/assistant.py @@ -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. """ diff --git a/src/langbot/pkg/api/http/service/assistant_tools.py b/src/langbot/pkg/api/http/service/assistant_tools.py index f5d468194..4d81f29e0 100644 --- a/src/langbot/pkg/api/http/service/assistant_tools.py +++ b/src/langbot/pkg/api/http/service/assistant_tools.py @@ -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': diff --git a/src/langbot/pkg/operation_trace/service.py b/src/langbot/pkg/operation_trace/service.py index 8df706640..9ceab345a 100644 --- a/src/langbot/pkg/operation_trace/service.py +++ b/src/langbot/pkg/operation_trace/service.py @@ -1653,6 +1653,7 @@ class WorkspaceSettingsService: since: datetime.datetime | None = None, until: datetime.datetime | None = None, integrity: str | None = None, + search: str | None = None, ) -> dict[str, typing.Any]: """Return one page of operation records plus a Workspace-wide summary. @@ -1664,6 +1665,13 @@ class WorkspaceSettingsService: ``integrity`` optionally narrows the listing to the records that failed verification, which lets the panel make its counters actionable instead of decorative. + + ``search`` is a case-insensitive substring match over the action, + resource type, resource id, actor name and summary. It exists so a + caller can ask a narrow question ("install", a plugin name, an account) + without pulling the whole history: the exact ``action`` filter compares + the stored key, so an approximate term like ``install`` would otherwise + return nothing and force a full dump. """ resolved_limit = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE)) @@ -1681,6 +1689,17 @@ class WorkspaceSettingsService: filters.append(model.resource_type == resource_type) if actor_account_uuid: filters.append(model.actor_account_uuid == actor_account_uuid) + if search: + needle = f'%{str(search).strip()}%' + filters.append( + sqlalchemy.or_( + model.action.ilike(needle), + model.resource_type.ilike(needle), + model.resource_id.ilike(needle), + model.actor_name.ilike(needle), + model.summary.ilike(needle), + ) + ) if level is not None: filters.append(model.level == int(level)) if since is not None: diff --git a/web/src/app/home/components/AssistantToolResult.tsx b/web/src/app/home/components/AssistantToolResult.tsx index 8b7e489e3..4eabe60c1 100644 --- a/web/src/app/home/components/AssistantToolResult.tsx +++ b/web/src/app/home/components/AssistantToolResult.tsx @@ -45,7 +45,9 @@ export default function AssistantToolResult({ ? result : Array.isArray(data.items) ? data.items - : null; + : Array.isArray(data.records) + ? data.records + : null; const total = typeof data.total === 'number' ? data.total : items?.length; const kind = tool?.arguments.kind; const label = @@ -142,7 +144,13 @@ export default function AssistantToolResult({ : {}; return (