572 lines
25 KiB
Python
572 lines
25 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Agent service: entry point for running AI agents with tool use."""
|
|
|
|
import logging
|
|
from typing import Any, Callable, Dict, List, Optional, AsyncGenerator
|
|
|
|
from modules.datamodels.datamodelAi import (
|
|
AiCallRequest, AiCallOptions, AiCallResponse, OperationTypeEnum
|
|
)
|
|
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
|
|
AgentConfig, AgentEvent, AgentEventTypeEnum
|
|
)
|
|
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
|
|
from modules.serviceCenter.services.serviceAgent.agentLoop import runAgentLoop
|
|
from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter
|
|
from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools
|
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import (
|
|
getService as getBillingService,
|
|
InsufficientBalanceException,
|
|
BillingContextError
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class _ServicesAdapter:
|
|
"""Adapter providing service access from (context, get_service)."""
|
|
|
|
def __init__(self, context, getService: Callable[[str], Any]):
|
|
self._context = context
|
|
self._getService = getService
|
|
self.user = context.user
|
|
self.mandateId = context.mandate_id
|
|
self.featureInstanceId = context.feature_instance_id
|
|
|
|
@property
|
|
def workflow(self):
|
|
return self._context.workflow
|
|
|
|
@property
|
|
def ai(self):
|
|
return self._getService("ai")
|
|
|
|
@property
|
|
def chat(self):
|
|
return self._getService("chat")
|
|
|
|
@property
|
|
def streaming(self):
|
|
return self._getService("streaming")
|
|
|
|
@property
|
|
def billing(self):
|
|
return self._getService("billing")
|
|
|
|
@property
|
|
def utils(self):
|
|
return self._getService("utils")
|
|
|
|
@property
|
|
def extraction(self):
|
|
return self._getService("extraction")
|
|
|
|
def getService(self, name: str):
|
|
"""Access any service by name."""
|
|
return self._getService(name)
|
|
|
|
@property
|
|
def featureCode(self) -> Optional[str]:
|
|
w = self.workflow
|
|
if w and hasattr(w, "feature") and w.feature:
|
|
return getattr(w.feature, "code", None)
|
|
return getattr(w, "featureCode", None) if w else None
|
|
|
|
|
|
class AgentService:
|
|
"""Service for running AI agents with ReAct loop and tool use.
|
|
|
|
Registered as IMPORTABLE_SERVICE with objectKey 'service.agent'.
|
|
Uses serviceAi for model selection/billing, streaming for SSE events.
|
|
"""
|
|
|
|
def __init__(self, context, get_service: Callable[[str], Any]):
|
|
self._context = context
|
|
self._getService = get_service
|
|
self.services = _ServicesAdapter(context, get_service)
|
|
|
|
async def runAgent(
|
|
self,
|
|
prompt: str,
|
|
fileIds: List[str] = None,
|
|
config: AgentConfig = None,
|
|
toolSet: str = "core",
|
|
workflowId: str = None,
|
|
additionalTools: List[Dict[str, Any]] = None,
|
|
userLanguage: str = "",
|
|
conversationHistory: List[Dict[str, Any]] = None,
|
|
buildRagContextFn: Callable = None,
|
|
systemPromptOverride: str = None,
|
|
) -> AsyncGenerator[AgentEvent, None]:
|
|
"""Run an agent with the given prompt and tools.
|
|
|
|
Args:
|
|
prompt: User prompt
|
|
fileIds: Optional list of file IDs to include as context
|
|
config: Agent configuration
|
|
toolSet: Which tool set to activate
|
|
workflowId: Workflow ID for tracking and billing
|
|
additionalTools: Extra tool definitions to register dynamically
|
|
userLanguage: ISO 639-1 language code; falls back to user.language from profile
|
|
conversationHistory: Prior messages for follow-up context
|
|
buildRagContextFn: Optional custom RAG context builder (overrides default)
|
|
systemPromptOverride: Optional system prompt override (replaces generated prompt)
|
|
|
|
Yields:
|
|
AgentEvent for each step (SSE-ready)
|
|
"""
|
|
if config is None:
|
|
config = AgentConfig(toolSet=toolSet)
|
|
|
|
if workflowId is None:
|
|
workflowId = getattr(self.services.workflow, "id", "unknown") if self.services.workflow else "unknown"
|
|
|
|
resolvedLanguage = userLanguage or getattr(self.services.user, "language", "") or "de"
|
|
|
|
enrichedPrompt = await self._enrichPromptWithFiles(prompt, fileIds)
|
|
|
|
toolRegistry = self._buildToolRegistry(config)
|
|
|
|
aiCallFn = self._createAiCallFn()
|
|
aiCallStreamFn = self._createAiCallStreamFn()
|
|
getWorkflowCostFn = self._createGetWorkflowCostFn(workflowId)
|
|
if buildRagContextFn is None:
|
|
buildRagContextFn = self._createBuildRagContextFn()
|
|
persistRoundMemoryFn = self._createPersistRoundMemoryFn(workflowId)
|
|
getExternalMemoryKeysFn = self._createGetExternalMemoryKeysFn(workflowId)
|
|
|
|
async for event in runAgentLoop(
|
|
prompt=enrichedPrompt,
|
|
toolRegistry=toolRegistry,
|
|
config=config,
|
|
aiCallFn=aiCallFn,
|
|
getWorkflowCostFn=getWorkflowCostFn,
|
|
workflowId=workflowId,
|
|
userId=self.services.user.id if self.services.user else "",
|
|
featureInstanceId=self.services.featureInstanceId or "",
|
|
buildRagContextFn=buildRagContextFn,
|
|
mandateId=self.services.mandateId or "",
|
|
aiCallStreamFn=aiCallStreamFn,
|
|
userLanguage=resolvedLanguage,
|
|
conversationHistory=conversationHistory,
|
|
persistRoundMemoryFn=persistRoundMemoryFn,
|
|
getExternalMemoryKeysFn=getExternalMemoryKeysFn,
|
|
systemPromptOverride=systemPromptOverride,
|
|
):
|
|
if event.type == AgentEventTypeEnum.AGENT_SUMMARY:
|
|
await self._persistTrace(workflowId, event.data or {})
|
|
if event.type != AgentEventTypeEnum.CHUNK:
|
|
logger.debug(f"runAgent yielding event type={event.type}")
|
|
yield event
|
|
logger.info(f"runAgent loop completed for workflow {workflowId}")
|
|
|
|
async def _enrichPromptWithFiles(self, prompt: str, fileIds: List[str] = None) -> str:
|
|
"""Resolve file metadata + FileContentIndex for attached fileIds and prepend to prompt.
|
|
|
|
The FileContentIndex is produced by the upload pipeline (AI-free extraction)
|
|
and tells the agent exactly which content objects (text, images, tables, etc.)
|
|
exist inside a file, so the agent can work with them directly via tools.
|
|
"""
|
|
if not fileIds:
|
|
return prompt
|
|
try:
|
|
chatService = self.services.chat
|
|
knowledgeDb = None
|
|
try:
|
|
from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
|
|
knowledgeDb = _getKnowledgeInterface()
|
|
except Exception:
|
|
pass
|
|
|
|
fileDescriptions = []
|
|
for fid in fileIds:
|
|
try:
|
|
info = chatService.getFileInfo(fid)
|
|
|
|
if not info:
|
|
folderInfo = chatService.interfaceDbComponent.getFolder(fid)
|
|
if folderInfo:
|
|
folderName = folderInfo.get("name", fid)
|
|
folderFiles = chatService.listFiles(folderId=fid)
|
|
desc = f"### Folder: {folderName}\n - id: {fid}\n - type: folder\n - contains: {len(folderFiles)} file(s)"
|
|
if folderFiles:
|
|
desc += "\n - files:"
|
|
for ff in folderFiles[:30]:
|
|
ffName = ff.get("fileName", "?")
|
|
ffId = ff.get("id", "?")
|
|
ffMime = ff.get("mimeType", "?")
|
|
ffSize = ff.get("fileSize", ff.get("size", "?"))
|
|
desc += f"\n * {ffName} (id: {ffId}, type: {ffMime}, size: {ffSize} bytes)"
|
|
if len(folderFiles) > 30:
|
|
desc += f"\n ... and {len(folderFiles) - 30} more files"
|
|
desc += f'\nUse `listFiles(folderId="{fid}")` to get the full file list, then `readFile(fileId)` to read individual files.'
|
|
fileDescriptions.append(desc)
|
|
continue
|
|
fileDescriptions.append(f"### File id: {fid}")
|
|
continue
|
|
|
|
fileName = info.get("fileName", fid)
|
|
mimeType = info.get("mimeType", "unknown")
|
|
fileSize = info.get("size", "?")
|
|
|
|
desc = f"### File: {fileName}\n - id: {fid}\n - type: {mimeType}\n - size: {fileSize} bytes"
|
|
|
|
if knowledgeDb:
|
|
contentIndex = knowledgeDb.getFileContentIndex(fid)
|
|
if contentIndex:
|
|
structure = contentIndex.get("structure", {})
|
|
totalObjects = contentIndex.get("totalObjects", 0)
|
|
desc += f"\n - indexed: yes ({totalObjects} content objects)"
|
|
if structure:
|
|
structParts = []
|
|
for key, val in structure.items():
|
|
if isinstance(val, (int, str)):
|
|
structParts.append(f"{key}: {val}")
|
|
if structParts:
|
|
desc += f"\n - structure: {', '.join(structParts)}"
|
|
|
|
objectSummary = contentIndex.get("objectSummary", [])
|
|
if objectSummary:
|
|
desc += "\n - content objects:"
|
|
for obj in objectSummary[:20]:
|
|
objType = obj.get("type", obj.get("contentType", "?"))
|
|
objRef = obj.get("ref", {})
|
|
objLabel = objRef.get("location", "") if isinstance(objRef, dict) else ""
|
|
objId = obj.get("id", obj.get("contentObjectId", ""))
|
|
desc += f"\n * [{objType}] {objLabel}" + (f" (id: {objId})" if objId else "")
|
|
if len(objectSummary) > 20:
|
|
desc += f"\n ... and {len(objectSummary) - 20} more objects"
|
|
else:
|
|
desc += "\n - indexed: no (use readFile to trigger extraction)"
|
|
|
|
fileDescriptions.append(desc)
|
|
except Exception:
|
|
fileDescriptions.append(f"### File id: {fid}")
|
|
|
|
if fileDescriptions:
|
|
header = (
|
|
"## Attached Files & Folders\n"
|
|
"These files/folders have been uploaded and processed through the extraction pipeline.\n"
|
|
"Use `readFile(fileId)` to read text content, `readContentObjects(fileId)` for structured access, "
|
|
"or `describeImage(fileId)` for image analysis.\n"
|
|
"For folders, use `listFiles(folderId)` to get the files inside, then `readFile(fileId)` for each.\n"
|
|
"For large PDFs/DOCX, avoid huge `renderDocument` tool JSON: build markdown with "
|
|
"`writeFile` (create + append), then `renderDocument(sourceFileId=that file id, outputFormat=...)`.\n"
|
|
"For small docs you may pass `content` inline. Embed images with `` in markdown.\n\n"
|
|
)
|
|
header += "\n\n".join(fileDescriptions)
|
|
return f"{header}\n\n---\n\nUser request: {prompt}"
|
|
except Exception as e:
|
|
logger.warning(f"Could not enrich prompt with file metadata: {e}")
|
|
return prompt
|
|
|
|
def _buildToolRegistry(self, config: AgentConfig) -> ToolRegistry:
|
|
"""Build a tool registry with core tools, ActionToolAdapter tools, and toolbox-activated tools."""
|
|
registry = ToolRegistry()
|
|
|
|
registerCoreTools(registry, self.services)
|
|
|
|
try:
|
|
from modules.workflows.processing.core.actionExecutor import ActionExecutor
|
|
actionExecutor = ActionExecutor(self.services)
|
|
adapter = ActionToolAdapter(actionExecutor)
|
|
adapter.registerAll(registry)
|
|
except Exception as e:
|
|
logger.warning(f"Could not register action tools: {e}")
|
|
|
|
self._activateToolboxes(registry, config)
|
|
self._registerRequestToolbox(registry)
|
|
|
|
return registry
|
|
|
|
def _activateToolboxes(self, registry: ToolRegistry, config: AgentConfig) -> None:
|
|
"""Activate toolboxes dynamically based on user connections and config.
|
|
|
|
For each active toolbox, marks already-registered tools as belonging to
|
|
that toolbox (via toolSet tag) so the agent loop can filter them.
|
|
The 'workflow' toolbox is special: its tools are registered from
|
|
workflowTools module because they have dedicated handlers.
|
|
"""
|
|
try:
|
|
from modules.serviceCenter.services.serviceAgent.toolboxRegistry import getToolboxRegistry
|
|
tbRegistry = getToolboxRegistry()
|
|
|
|
userConnections: List[str] = []
|
|
try:
|
|
connectionService = self._getService("connection")
|
|
if connectionService and hasattr(connectionService, "getConnections"):
|
|
connections = connectionService.getConnections() or []
|
|
userConnections = [c.get("authority", "") for c in connections if c.get("authority")]
|
|
except Exception as e:
|
|
logger.debug("Could not resolve user connections for toolbox activation: %s", e)
|
|
|
|
activeToolboxes = tbRegistry.getActiveToolboxes(userConnections)
|
|
activatedIds = [tb.id for tb in activeToolboxes]
|
|
logger.info("Toolbox activation: connections=%s -> active toolboxes=%s", userConnections, activatedIds)
|
|
|
|
activeToolNames: set = set()
|
|
for tb in activeToolboxes:
|
|
activeToolNames.update(tb.tools)
|
|
|
|
for tb in activeToolboxes:
|
|
if tb.id == "workflow":
|
|
try:
|
|
from modules.serviceCenter.services.serviceAgent.workflowTools import getWorkflowToolDefinitions
|
|
wfDefs = getWorkflowToolDefinitions()
|
|
for toolDef in wfDefs:
|
|
registry.registerFromDefinition(toolDef, toolDef._handler if hasattr(toolDef, "_handler") else None)
|
|
logger.info("Registered %d workflow tools from toolbox", len(wfDefs))
|
|
except Exception as e:
|
|
logger.warning("Could not register workflow tools: %s", e)
|
|
|
|
inactiveToolNames = set()
|
|
for tb in tbRegistry.getAllToolboxes():
|
|
if tb.id not in activatedIds:
|
|
inactiveToolNames.update(tb.tools)
|
|
inactiveToolNames -= activeToolNames
|
|
|
|
for toolName in inactiveToolNames:
|
|
registry.unregister(toolName)
|
|
|
|
logger.debug("Toolbox activation: %d active tools, %d inactive tools removed", len(activeToolNames), len(inactiveToolNames))
|
|
except Exception as e:
|
|
logger.warning("Toolbox activation failed: %s", e)
|
|
|
|
def _registerRequestToolbox(self, registry: ToolRegistry) -> None:
|
|
"""Register the requestToolbox meta-tool that lets the agent dynamically activate toolboxes."""
|
|
try:
|
|
from modules.serviceCenter.services.serviceAgent.toolboxRegistry import (
|
|
getToolboxRegistry, buildRequestToolboxDefinition, REQUEST_TOOLBOX_TOOL_NAME,
|
|
)
|
|
from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
|
|
|
|
tbRegistry = getToolboxRegistry()
|
|
allIds = [tb.id for tb in tbRegistry.getAllToolboxes()]
|
|
registeredNames = set(registry.getToolNames())
|
|
inactiveIds = [tbId for tbId in allIds if not any(
|
|
t in registeredNames for t in (tbRegistry.getToolbox(tbId).tools if tbRegistry.getToolbox(tbId) else [])
|
|
)]
|
|
|
|
if not inactiveIds:
|
|
return
|
|
|
|
toolDef = buildRequestToolboxDefinition(inactiveIds)
|
|
|
|
async def _handler(args: Dict[str, Any], context: Dict[str, Any] = None) -> ToolResult:
|
|
toolboxId = args.get("toolboxId", "")
|
|
reason = args.get("reason", "")
|
|
tb = tbRegistry.getToolbox(toolboxId)
|
|
if not tb:
|
|
return ToolResult(
|
|
toolCallId="", toolName=REQUEST_TOOLBOX_TOOL_NAME,
|
|
success=False, error=f"Unknown toolbox: {toolboxId}",
|
|
)
|
|
for toolName in tb.tools:
|
|
if not registry.isValidTool(toolName):
|
|
logger.info("requestToolbox: tool '%s' from toolbox '%s' not yet registered, skipping", toolName, toolboxId)
|
|
continue
|
|
logger.info("requestToolbox: activated toolbox '%s' (%d tools). Reason: %s", toolboxId, len(tb.tools), reason)
|
|
return ToolResult(
|
|
toolCallId="", toolName=REQUEST_TOOLBOX_TOOL_NAME,
|
|
success=True,
|
|
data=f"Toolbox '{tb.label}' activated with {len(tb.tools)} tools. They are now available.",
|
|
)
|
|
|
|
registry.register(
|
|
name=REQUEST_TOOLBOX_TOOL_NAME,
|
|
handler=_handler,
|
|
description=toolDef["description"],
|
|
parameters=toolDef["parameters"],
|
|
)
|
|
logger.info("Registered requestToolbox meta-tool (inactive toolboxes: %s)", inactiveIds)
|
|
except Exception as e:
|
|
logger.warning("Could not register requestToolbox meta-tool: %s", e)
|
|
|
|
async def _persistTrace(self, workflowId: str, summaryData: Dict[str, Any]):
|
|
"""Persist the agent trace and workflow artifacts in the knowledge store."""
|
|
try:
|
|
knowledgeService = self._getService("knowledge")
|
|
userId = self.services.user.id if self.services.user else ""
|
|
featureInstanceId = self.services.featureInstanceId or ""
|
|
|
|
import json
|
|
traceValue = json.dumps(summaryData, default=str)
|
|
|
|
await knowledgeService.storeEntity(
|
|
workflowId=workflowId,
|
|
userId=userId,
|
|
featureInstanceId=featureInstanceId,
|
|
key="_agentTrace",
|
|
value=traceValue,
|
|
source="agent",
|
|
)
|
|
|
|
artifacts = summaryData.get("artifacts", "")
|
|
if artifacts:
|
|
await knowledgeService.storeEntity(
|
|
workflowId=workflowId,
|
|
userId=userId,
|
|
featureInstanceId=featureInstanceId,
|
|
key="_workflowArtifacts",
|
|
value=artifacts,
|
|
source="agent",
|
|
)
|
|
logger.info(f"Persisted workflow artifacts for workflow {workflowId}")
|
|
|
|
logger.info(f"Persisted agent trace for workflow {workflowId}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not persist agent trace: {e}")
|
|
|
|
def _createAiCallFn(self) -> Callable[[AiCallRequest], AiCallResponse]:
|
|
"""Create the AI call function that wraps serviceAi with billing."""
|
|
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
|
async def _aiCallFn(request: AiCallRequest) -> AiCallResponse:
|
|
if ctxNeutralization is not None and request.requireNeutralization is None:
|
|
request.requireNeutralization = ctxNeutralization
|
|
aiService = self.services.ai
|
|
return await aiService.callAi(request)
|
|
return _aiCallFn
|
|
|
|
def _createAiCallStreamFn(self):
|
|
"""Create the streaming AI call function. Yields str deltas, then AiCallResponse."""
|
|
ctxNeutralization = getattr(self._context, "requireNeutralization", None)
|
|
async def _aiCallStreamFn(request: AiCallRequest):
|
|
if ctxNeutralization is not None and request.requireNeutralization is None:
|
|
request.requireNeutralization = ctxNeutralization
|
|
aiService = self.services.ai
|
|
async for chunk in aiService.callAiStream(request):
|
|
yield chunk
|
|
return _aiCallStreamFn
|
|
|
|
def _createGetWorkflowCostFn(self, workflowId: str) -> Callable[[], float]:
|
|
"""Create a function that returns the current workflow cost."""
|
|
async def _getWorkflowCost() -> float:
|
|
try:
|
|
billingService = self.services.billing
|
|
return await billingService.getWorkflowCost(workflowId)
|
|
except Exception:
|
|
return 0.0
|
|
return _getWorkflowCost
|
|
|
|
def _createBuildRagContextFn(self):
|
|
"""Create the RAG context builder function that delegates to KnowledgeService."""
|
|
async def _buildRagContext(
|
|
currentPrompt: str, workflowId: str, userId: str,
|
|
featureInstanceId: str, mandateId: str, **kwargs
|
|
) -> str:
|
|
try:
|
|
knowledgeService = self.services.getService("knowledge")
|
|
workflowHintItems = _buildWorkflowHintItems(
|
|
self.services, workflowId
|
|
)
|
|
return await knowledgeService.buildAgentContext(
|
|
currentPrompt=currentPrompt,
|
|
workflowId=workflowId,
|
|
userId=userId,
|
|
featureInstanceId=featureInstanceId,
|
|
mandateId=mandateId,
|
|
workflowHintItems=workflowHintItems,
|
|
isSysAdmin=getattr(self.services.user, "isSysAdmin", False),
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"RAG context not available: {e}")
|
|
return ""
|
|
return _buildRagContext
|
|
|
|
def _createPersistRoundMemoryFn(self, workflowId: str):
|
|
"""Create callback that persists RoundMemory entries after tool execution."""
|
|
from modules.serviceCenter.services.serviceAgent.agentLoop import _classifyToolResult
|
|
from modules.datamodels.datamodelKnowledge import RoundMemory
|
|
|
|
async def _persistRoundMemory(
|
|
toolCalls, results, textContent: str, roundNumber: int
|
|
):
|
|
try:
|
|
knowledgeService = self.services.getService("knowledge")
|
|
except Exception:
|
|
return
|
|
knowledgeDb = knowledgeService._knowledgeDb
|
|
|
|
for tc, result in zip(toolCalls, results):
|
|
if not result.success:
|
|
continue
|
|
classified = _classifyToolResult(tc, result)
|
|
if not classified:
|
|
continue
|
|
|
|
summary = classified["summary"]
|
|
embedding = await knowledgeService._embedSingle(summary[:500]) if summary else []
|
|
|
|
mem = RoundMemory(
|
|
workflowId=workflowId,
|
|
roundNumber=roundNumber,
|
|
memoryType=classified["memoryType"],
|
|
key=classified["key"],
|
|
summary=summary,
|
|
fullData=classified.get("fullData"),
|
|
fileIds=classified.get("fileIds", []),
|
|
embedding=embedding if embedding else None,
|
|
)
|
|
knowledgeDb.storeRoundMemory(mem)
|
|
|
|
return _persistRoundMemory
|
|
|
|
def _createGetExternalMemoryKeysFn(self, workflowId: str):
|
|
"""Create callback that returns RoundMemory keys for summarization hints."""
|
|
def _getKeys() -> List[str]:
|
|
try:
|
|
knowledgeService = self.services.getService("knowledge")
|
|
memories = knowledgeService._knowledgeDb.getRoundMemories(workflowId)
|
|
return [m.get("key", "") for m in memories if m.get("key")]
|
|
except Exception:
|
|
return []
|
|
return _getKeys
|
|
|
|
|
|
def _buildWorkflowHintItems(
|
|
services, currentWorkflowId: str
|
|
) -> List[Dict[str, Any]]:
|
|
"""Build a compact list of other workflows for the RAG cross-workflow hint.
|
|
|
|
Returns key-value items like:
|
|
key="Pendenzenliste Excel (3 msgs)" value="last: 2h ago"
|
|
Limited to 10 most recent other workflows to keep the hint small.
|
|
"""
|
|
try:
|
|
chatInterface = services.chat.interfaceDbChat
|
|
allWorkflows = chatInterface.getWorkflows() or []
|
|
except Exception:
|
|
return []
|
|
|
|
others = [w for w in allWorkflows if w.get("id") != currentWorkflowId]
|
|
if not others:
|
|
return []
|
|
|
|
import time as _time
|
|
now = _time.time()
|
|
others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True)
|
|
others = others[:10]
|
|
|
|
items = []
|
|
for wf in others:
|
|
name = wf.get("name") or "(unnamed)"
|
|
createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0
|
|
ageSec = now - createdAt if createdAt else 0
|
|
if ageSec < 3600:
|
|
ageStr = f"{int(ageSec / 60)}m ago"
|
|
elif ageSec < 86400:
|
|
ageStr = f"{int(ageSec / 3600)}h ago"
|
|
else:
|
|
ageStr = f"{int(ageSec / 86400)}d ago"
|
|
|
|
wfId = wf.get("id", "")
|
|
items.append({
|
|
"key": f"{name} (id: {wfId})",
|
|
"value": ageStr,
|
|
})
|
|
|
|
countLabel = f"{len(allWorkflows) - 1} other conversation(s)"
|
|
if len(allWorkflows) - 1 > 10:
|
|
countLabel += f" (showing 10 newest)"
|
|
items.insert(0, {"key": countLabel, "value": "use listWorkflowHistory to browse"})
|
|
return items
|