Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.252"
VERSION = "0.261.255"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
132 changes: 132 additions & 0 deletions application/single_app/functions_collaboration_ai_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# functions_collaboration_ai_activity.py
"""Plain-language steps for the activity line of a shared conversation.

Check warning on line 2 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Thoughts are written for whoever debugs a request: plugin and function names, model deployment

Check warning on line 4 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
names, argument values and timings. The other participants only need to know what the assistant
is doing, so each stream event is described in plain words before it is broadcast. An event that
is not recognised describes as an empty string, which leaves the line on its previous step.

Check warning on line 7 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
"""

import re


THINKING_STEP = 'Thinking'
WRITING_STEP = 'Writing the answer'
REVIEWING_STEP = 'Reviewing what it found'
FINISHING_STEP = 'Finishing up'

STEP_TYPE_PHRASES = {
'history_context': 'Reading the conversation',

Check warning on line 19 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
'search': 'Searching documents',

Check warning on line 20 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
'web_search': 'Searching the web',

Check warning on line 21 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
'deep_research': 'Researching the web',

Check warning on line 22 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
'url_access': 'Reading a web page',
'tabular_analysis': 'Analyzing the data',
'fact_memory': 'Checking saved facts',
'orchestration_triage': 'Planning the work',
'orchestration_planning': 'Planning the work',
'orchestration_step': 'Working through the plan',
'orchestration_synthesis': 'Putting the answer together',
}

PLUGIN_PHRASES = {

Check warning on line 32 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
'DocumentSearchPlugin': 'Searching documents',

Check warning on line 33 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Check warning on line 33 in application/single_app/functions_collaboration_ai_activity.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
'SharedMapPlugin': 'Updating the map',
'MathPlugin': 'Calculating',
'WaitPlugin': 'Waiting',
}

VERB_PHRASES = {
'get': 'Looking up', 'fetch': 'Looking up', 'retrieve': 'Looking up', 'lookup': 'Looking up',
'read': 'Reading', 'find': 'Finding', 'search': 'Searching', 'query': 'Searching',
'list': 'Reviewing', 'show': 'Showing', 'view': 'Viewing', 'check': 'Checking', 'verify': 'Checking',
'validate': 'Checking', 'create': 'Creating', 'new': 'Creating', 'add': 'Adding', 'open': 'Opening',
'start': 'Starting', 'update': 'Updating', 'set': 'Updating', 'edit': 'Updating', 'patch': 'Updating',
'submit': 'Submitting', 'send': 'Sending', 'post': 'Posting', 'file': 'Filing', 'upload': 'Saving',
'save': 'Saving', 'draft': 'Drafting', 'generate': 'Generating', 'write': 'Writing',
'delete': 'Removing', 'remove': 'Removing', 'close': 'Closing', 'cancel': 'Cancelling',
'approve': 'Approving', 'sign': 'Signing', 'analyze': 'Analyzing', 'summarize': 'Summarizing',
'calculate': 'Calculating', 'run': 'Running', 'schedule': 'Scheduling', 'assign': 'Assigning',
'invite': 'Inviting', 'notify': 'Notifying', 'publish': 'Publishing', 'export': 'Exporting',
'download': 'Downloading', 'claim': 'Claiming', 'record': 'Recording', 'link': 'Linking',
}

PROPER_WORDS = {
'word': 'Word', 'powerpoint': 'PowerPoint', 'excel': 'Excel', 'markdown': 'Markdown',
'pdf': 'PDF', 'teams': 'Teams', 'outlook': 'Outlook',
}

FUNCTION_WORD_PATTERN = re.compile(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+')


def _text(value):
return str(value or '').strip()


def _display_word(word):
if len(word) > 1 and word.isupper():
return word
return PROPER_WORDS.get(word.lower(), word.lower())


def describe_function_name(function_name):
"""'getOrderStatus' -> 'Looking up order status', 'search_documents' -> 'Searching documents'."""
words = FUNCTION_WORD_PATTERN.findall(_text(function_name))
if not words:
return ''
verb = VERB_PHRASES.get(words[0].lower())
rest = [_display_word(word) for word in (words[1:] if verb else words)]
if not verb:
return f"Using {' '.join(rest)}"
return ' '.join([verb, *rest])


def _describe_tool_invocation(activity):
status = _text(activity.get('status') or activity.get('state')).lower()
if status == 'failed':
return ''
plugin_name = _text(activity.get('plugin_name'))
if plugin_name == 'AgentPlugin':
delegation = activity.get('delegation') if isinstance(activity.get('delegation'), dict) else {}
target = _text(delegation.get('target_label'))
if status == 'completed':
return f"Reviewing {target}'s reply" if target else REVIEWING_STEP
return f'Asking {target}' if target else 'Asking another agent'
if status == 'completed':
return REVIEWING_STEP
return PLUGIN_PHRASES.get(plugin_name) or describe_function_name(activity.get('function_name'))


def _describe_generation(content):
lowered = content.lower()
if lowered.startswith('sending to'):
return THINKING_STEP
if 'responded' in lowered or lowered.startswith('image generated'):
return FINISHING_STEP
if 'image' in lowered:
return 'Creating the image'
return THINKING_STEP


def describe_ai_activity_step(payload):
"""Describe one collaboration stream event for the activity line, or return '' to skip it."""
if not isinstance(payload, dict) or payload.get('done') or payload.get('error'):
return ''

if payload.get('type') is None:
content = payload.get('content')
return WRITING_STEP if isinstance(content, str) and content.strip() else ''
if payload.get('type') != 'thought':
return ''

activity = payload.get('activity')
if isinstance(activity, dict) and _text(activity.get('kind')) == 'tool_invocation':
return _describe_tool_invocation(activity)

step_type = _text(payload.get('step_type')).lower()
content = _text(payload.get('content'))
if step_type == 'generation':
return _describe_generation(content)
if step_type == 'agent_tool_call' and content.lower().startswith('sending to agent'):
return 'Getting started'
return STEP_TYPE_PHRASES.get(step_type, '')
153 changes: 153 additions & 0 deletions application/single_app/functions_collaboration_generated_documents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# functions_collaboration_generated_documents.py
"""Documents that agents generated in a shared conversation, and who may download them.

An agent creates a document with the SimpleChat action's upload functions. Each stores the file
in the requester's personal workspace or in a group workspace and returns its id, which stays on
the message in the agent citation. That is what lets a shared conversation list what was produced
in it. Listing reveals only file names; downloading follows the workspace's own download rules,
whichever group the reader currently has active.
"""

import json
import os
import re

from functions_documents import get_document_record
from functions_group import assert_group_role, find_group_by_id
from functions_settings import (
get_settings,
is_group_workspace_file_download_enabled,
is_personal_workspace_file_download_enabled,
)

GENERATED_DOCUMENT_FUNCTIONS = frozenset({
'upload_markdown_document',
'upload_word_document',
'upload_powerpoint_document',
})
# The roles the group workspace's own download route requires
# (GROUP_DOCUMENT_DOWNLOAD_MANAGER_ROLES in route_backend_group_documents.py).
GROUP_DOWNLOAD_ROLES = ('Owner', 'Admin', 'DocumentManager')
DOCUMENT_ID_PATTERN = re.compile(r'^[A-Za-z0-9-]{8,64}$')
MARKDOWN_EXTENSIONS = frozenset({'.md', '.markdown'})
FILE_NAME_MAX_LENGTH = 255


def _parse_result(value):
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
return None
return value if isinstance(value, dict) else None


def read_generated_document(citation):
"""Return the document an upload citation created, or None for any other citation."""
if not isinstance(citation, dict):
return None
if str(citation.get('function_name') or '').strip() not in GENERATED_DOCUMENT_FUNCTIONS:
return None
result = _parse_result(citation.get('function_result'))
if not result or result.get('success') is False:
return None
document = result.get('document')
if not isinstance(document, dict):
return None

document_id = str(document.get('id') or '').strip()
if not DOCUMENT_ID_PATTERN.match(document_id):
return None
scope = str(result.get('workspace_scope') or '').strip().lower()
group_id = str(result.get('group_id') or '').strip()
if scope == 'group':
if not group_id:
return None
elif scope == 'personal':
group_id = ''
else:
return None

raw_name = str(document.get('file_name') or '').replace('\\', '/')
file_name = os.path.basename(raw_name).strip()[:FILE_NAME_MAX_LENGTH] or 'document'
extension = os.path.splitext(file_name)[1].lower()
return {
'document_id': document_id,
'file_name': file_name,
'workspace_scope': scope,
'group_id': group_id or None,
'preview': 'markdown' if extension in MARKDOWN_EXTENSIONS else None,
}


def collect_generated_documents(messages):
"""List every document generated in a conversation's messages, oldest first, once each.

A fully masked message is skipped: masking it hid what it said, including what it produced.
"""
found = {}
for message in messages or []:
if not isinstance(message, dict):
continue
metadata = message.get('metadata')
if isinstance(metadata, dict) and metadata.get('masked') is True:
continue
citations = message.get('agent_citations')
if not isinstance(citations, list):
continue
for citation in citations:
document = read_generated_document(citation)
if not document or document['document_id'] in found:
continue
document['message_id'] = str(message.get('id') or '')
document['created_at'] = str(
(citation.get('timestamp') if isinstance(citation, dict) else '')
or message.get('timestamp')
or ''
)
found[document['document_id']] = document
return list(found.values())


def authorize_generated_document_download(user_id, document, settings=None):
"""Return ``(document_record, group_id)`` when ``user_id`` may download ``document``.

Raises ``PermissionError`` when the workspace's download rules refuse it and ``LookupError``
when the document cannot be read by this user, which includes another person's personal
document. A group document needs a document-managing role in that group and downloads
allowed for it; a personal one needs to be the reader's own with personal downloads allowed.
"""
settings = settings if settings is not None else get_settings()
document_id = document['document_id']
if document['workspace_scope'] == 'group':
if not settings.get('enable_group_workspaces', False):
raise PermissionError('Group workspaces are disabled')
group_id = document['group_id']
group_doc = find_group_by_id(group_id)
if not group_doc:
raise LookupError('Document not found')
assert_group_role(user_id, group_id, allowed_roles=GROUP_DOWNLOAD_ROLES)
if not is_group_workspace_file_download_enabled(settings, group_doc):
raise PermissionError('File downloads are disabled for this group workspace')
record = get_document_record(user_id=user_id, document_id=document_id, group_id=group_id)
if not record:
raise LookupError('Document not found')
return record, group_id

if not settings.get('enable_user_workspace', False):
raise PermissionError('Personal workspaces are disabled')
if not is_personal_workspace_file_download_enabled(settings):
raise PermissionError('File downloads are disabled for personal workspaces')
record = get_document_record(user_id=user_id, document_id=document_id)
if not record:
raise LookupError('Document not found')
return record, None


def can_download_generated_document(user_id, document, settings=None):
"""Whether the download would be allowed, for the list's Download control."""
try:
authorize_generated_document_download(user_id, document, settings=settings)
except (LookupError, PermissionError):
return False
return True
Loading
Loading