tools enhancement and cleanup
This commit is contained in:
parent
9b741a0a28
commit
b876f01da2
4 changed files with 363 additions and 277 deletions
|
|
@ -21,6 +21,7 @@ from modules.serviceCenter.services.serviceAgent.conversationManager import (
|
|||
ConversationManager, buildSystemPrompt
|
||||
)
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.shared.jsonUtils import closeJsonStructures
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -228,7 +229,8 @@ async def runAgentLoop(
|
|||
args=next((tc.args for tc in toolCalls if tc.id == result.toolCallId), {}),
|
||||
success=result.success,
|
||||
durationMs=result.durationMs,
|
||||
error=result.error
|
||||
error=result.error,
|
||||
resultData=result.data[:300] if result.data else "",
|
||||
))
|
||||
if not result.success:
|
||||
logger.warning(f"Tool '{result.toolName}' failed: {result.error}")
|
||||
|
|
@ -282,6 +284,8 @@ async def runAgentLoop(
|
|||
trace.totalCostCHF = state.totalCostCHF
|
||||
trace.abortReason = state.abortReason
|
||||
|
||||
artifactSummary = _buildArtifactSummary(trace.rounds)
|
||||
|
||||
yield AgentEvent(
|
||||
type=AgentEventTypeEnum.AGENT_SUMMARY,
|
||||
data={
|
||||
|
|
@ -291,7 +295,8 @@ async def runAgentLoop(
|
|||
"costCHF": round(state.totalCostCHF, 4),
|
||||
"processingTime": round(state.totalProcessingTime, 2),
|
||||
"status": state.status.value,
|
||||
"abortReason": state.abortReason
|
||||
"abortReason": state.abortReason,
|
||||
"artifacts": artifactSummary,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -351,46 +356,19 @@ async def _executeToolCalls(toolCalls: List[ToolCallRequest],
|
|||
|
||||
|
||||
def _repairTruncatedJson(raw: str) -> Optional[Dict[str, Any]]:
|
||||
"""Try to repair truncated JSON from LLM output by closing open brackets/braces.
|
||||
"""Repair truncated JSON using the shared jsonUtils toolbox.
|
||||
|
||||
Uses closeJsonStructures which handles open strings, brackets, braces,
|
||||
and trailing commas with stack-based structure tracking.
|
||||
Returns parsed dict on success, None if unrecoverable.
|
||||
"""
|
||||
if not raw or not raw.strip().startswith("{"):
|
||||
return None
|
||||
|
||||
openBraces = raw.count("{") - raw.count("}")
|
||||
openBrackets = raw.count("[") - raw.count("]")
|
||||
|
||||
inString = False
|
||||
lastQuoteEscaped = False
|
||||
quoteCount = 0
|
||||
for ch in raw:
|
||||
if ch == '"' and not lastQuoteEscaped:
|
||||
quoteCount += 1
|
||||
inString = not inString
|
||||
lastQuoteEscaped = (ch == '\\')
|
||||
|
||||
candidate = raw
|
||||
if quoteCount % 2 != 0:
|
||||
candidate += '"'
|
||||
|
||||
candidate += "]" * max(0, openBrackets)
|
||||
candidate += "}" * max(0, openBraces)
|
||||
|
||||
try:
|
||||
return json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
lastComma = candidate.rfind(",")
|
||||
if lastComma > 0:
|
||||
trimmed = candidate[:lastComma] + candidate[lastComma + 1:]
|
||||
try:
|
||||
return json.loads(trimmed)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return None
|
||||
closed = closeJsonStructures(raw)
|
||||
return json.loads(closed)
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]:
|
||||
|
|
@ -409,7 +387,14 @@ def _parseToolCalls(aiResponse: AiCallResponse) -> List[ToolCallRequest]:
|
|||
parsedArgs = _repairTruncatedJson(rawArgs)
|
||||
if parsedArgs is None:
|
||||
logger.warning(f"Unrecoverable truncated JSON for '{tc['function']['name']}': {rawArgs[:200]}")
|
||||
parsedArgs = {"_parseError": f"Truncated JSON arguments – model output was cut off. Raw start: {rawArgs[:120]}"}
|
||||
parsedArgs = {"_parseError": (
|
||||
"Your tool call arguments were truncated (output cut off by token limit). "
|
||||
"The content is too large for a single tool call. Strategies:\n"
|
||||
"1. For new files: use writeFile(mode='create') with the first part, "
|
||||
"then writeFile(fileId=..., mode='append') for subsequent parts (~8000 chars each).\n"
|
||||
"2. For editing existing files: use replaceInFile to change only the specific parts.\n"
|
||||
"3. For documentation: split into multiple smaller files."
|
||||
)}
|
||||
else:
|
||||
logger.info(f"Repaired truncated JSON for '{tc['function']['name']}'")
|
||||
else:
|
||||
|
|
@ -471,3 +456,24 @@ def _buildProgressSummary(state: AgentState, reason: str) -> str:
|
|||
f"- Cost: {state.totalCostCHF:.4f} CHF\n"
|
||||
f"- Processing time: {state.totalProcessingTime:.1f}s"
|
||||
)
|
||||
|
||||
|
||||
_ARTIFACT_TOOLS = {"writeFile", "replaceInFile", "deleteFile", "renameFile", "copyFile",
|
||||
"createFolder", "deleteFolder", "renderDocument", "generateImage"}
|
||||
|
||||
def _buildArtifactSummary(roundLogs: List[AgentRoundLog]) -> str:
|
||||
"""Extract file operations and key results from all agent rounds.
|
||||
|
||||
Produces a concise summary persisted as _workflowArtifacts so
|
||||
follow-up rounds have immediate context (file IDs, names, actions).
|
||||
"""
|
||||
ops = []
|
||||
for log in roundLogs:
|
||||
for tc in log.toolCalls:
|
||||
if tc.toolName not in _ARTIFACT_TOOLS or not tc.success:
|
||||
continue
|
||||
ops.append(f"- {tc.resultData}" if tc.resultData else f"- {tc.toolName}")
|
||||
|
||||
if not ops:
|
||||
return ""
|
||||
return "File operations in this run:\n" + "\n".join(ops)
|
||||
|
|
|
|||
|
|
@ -296,6 +296,29 @@ def buildSystemPrompt(
|
|||
"Think step by step. Call tools when you need information or need to perform actions. "
|
||||
"When you have enough information to answer, respond directly without calling tools.\n\n"
|
||||
)
|
||||
|
||||
prompt += (
|
||||
"## Working Guidelines\n\n"
|
||||
"### Workflow Context\n"
|
||||
"When continuing a workflow (follow-up message), the Relevant Knowledge section contains "
|
||||
"artifacts from previous rounds (file IDs, operations). Use this context instead of "
|
||||
"re-searching or re-listing files.\n\n"
|
||||
"### Efficient File Editing\n"
|
||||
"- Use readFile with offset/limit to read specific line ranges of large files.\n"
|
||||
"- Use searchInFileContent to find text before editing.\n"
|
||||
"- Use replaceInFile for targeted edits (preferred over rewriting entire files).\n"
|
||||
"- Use writeFile(mode='overwrite') only when the entire content must change.\n\n"
|
||||
"### Large Content Strategy\n"
|
||||
"- For content larger than ~8000 characters: use writeFile(mode='create') for the first "
|
||||
"part, then writeFile(fileId=..., mode='append') for subsequent parts.\n"
|
||||
"- Split large documentation into multiple focused files rather than one huge document.\n"
|
||||
"- Structure outputs so files reference each other (e.g. index.md linking to sections).\n\n"
|
||||
"### Code Generation\n"
|
||||
"- Prefer modular file structures over monolithic files.\n"
|
||||
"- When generating applications, create separate files for logical components.\n"
|
||||
"- Always plan the structure before writing code.\n\n"
|
||||
)
|
||||
|
||||
if toolsFormatted:
|
||||
prompt += f"Available Tools:\n{toolsFormatted}\n\n"
|
||||
prompt += (
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ class ToolCallLog(BaseModel):
|
|||
success: bool = True
|
||||
durationMs: int = 0
|
||||
error: Optional[str] = None
|
||||
resultData: str = Field(default="", description="Short result summary for artifact tracking")
|
||||
|
||||
|
||||
class AgentRoundLog(BaseModel):
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ class AgentService:
|
|||
return registry
|
||||
|
||||
async def _persistTrace(self, workflowId: str, summaryData: Dict[str, Any]):
|
||||
"""Persist the agent trace as a workflow memory entry in the knowledge store."""
|
||||
"""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 ""
|
||||
|
|
@ -297,6 +297,19 @@ class AgentService:
|
|||
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}")
|
||||
|
|
@ -372,8 +385,23 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
# ---- Read-only tools ----
|
||||
|
||||
def _applyOffsetLimit(text: str, offset: int = None, limit: int = None) -> str:
|
||||
"""Apply line-based offset/limit to text content, returning numbered lines."""
|
||||
if offset is None and limit is None:
|
||||
return None
|
||||
lines = text.split("\n")
|
||||
totalLines = len(lines)
|
||||
startLine = max(0, (offset or 1) - 1)
|
||||
endLine = min(totalLines, startLine + (limit or 200))
|
||||
selected = lines[startLine:endLine]
|
||||
numbered = "\n".join(f"{i + startLine + 1}|{line}" for i, line in enumerate(selected))
|
||||
header = f"[Lines {startLine + 1}-{endLine} of {totalLines} total]\n"
|
||||
return header + numbered
|
||||
|
||||
async def _readFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
fileId = args.get("fileId", "")
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if not fileId:
|
||||
return ToolResult(toolCallId="", toolName="readFile", success=False, error="fileId is required")
|
||||
try:
|
||||
|
|
@ -390,8 +418,11 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
]
|
||||
if textChunks:
|
||||
assembled = "\n\n".join(c["data"] for c in textChunks)
|
||||
chunked = _applyOffsetLimit(assembled, offset, limit)
|
||||
if chunked is not None:
|
||||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||||
if len(assembled) > _MAX_TOOL_RESULT_CHARS:
|
||||
assembled = assembled[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(assembled)}]"
|
||||
assembled = assembled[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(assembled)}. Use offset/limit to read specific sections.]"
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="readFile", success=True,
|
||||
data=assembled,
|
||||
|
|
@ -466,8 +497,11 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
textParts = [o["data"] for o in contentObjects if o["contentType"] != "image"]
|
||||
if textParts:
|
||||
joined = "\n\n".join(textParts)
|
||||
chunked = _applyOffsetLimit(joined, offset, limit)
|
||||
if chunked is not None:
|
||||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||||
if len(joined) > _MAX_TOOL_RESULT_CHARS:
|
||||
joined = joined[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(joined)}]"
|
||||
joined = joined[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(joined)}. Use offset/limit to read specific sections.]"
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="readFile", success=True,
|
||||
data=joined,
|
||||
|
|
@ -493,8 +527,11 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
try:
|
||||
text = rawBytes.decode(encoding)
|
||||
if text.strip():
|
||||
chunked = _applyOffsetLimit(text, offset, limit)
|
||||
if chunked is not None:
|
||||
return ToolResult(toolCallId="", toolName="readFile", success=True, data=chunked)
|
||||
if len(text) > _MAX_TOOL_RESULT_CHARS:
|
||||
text = text[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(text)}]"
|
||||
text = text[:_MAX_TOOL_RESULT_CHARS] + f"\n\n[Truncated – showing first {_MAX_TOOL_RESULT_CHARS} chars of {len(text)}. Use offset/limit to read specific sections.]"
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="readFile", success=True,
|
||||
data=text,
|
||||
|
|
@ -527,20 +564,44 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="listFiles", success=False, error=str(e))
|
||||
|
||||
async def _searchFiles(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
async def _searchInFileContent(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
import re as _re
|
||||
fileId = args.get("fileId", "")
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return ToolResult(toolCallId="", toolName="searchFiles", success=False, error="query is required")
|
||||
contextLines = args.get("contextLines", 2)
|
||||
if not fileId or not query:
|
||||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error="fileId and query are required")
|
||||
try:
|
||||
chatService = services.chat
|
||||
files = chatService.listFiles(search=query, tags=args.get("tags"))
|
||||
fileList = "\n".join(
|
||||
f"- {f.get('fileName', 'unknown')} (id: {f.get('id', '?')})"
|
||||
for f in files
|
||||
) if files else "No files matching query."
|
||||
return ToolResult(toolCallId="", toolName="searchFiles", success=True, data=fileList)
|
||||
rawBytes = chatService.getFileData(fileId)
|
||||
if not rawBytes:
|
||||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error="File data not accessible")
|
||||
try:
|
||||
content = rawBytes.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = rawBytes.decode("latin-1", errors="replace")
|
||||
|
||||
lines = content.split("\n")
|
||||
pattern = _re.compile(_re.escape(query), _re.IGNORECASE)
|
||||
matches = []
|
||||
for i, line in enumerate(lines):
|
||||
if pattern.search(line):
|
||||
start = max(0, i - contextLines)
|
||||
end = min(len(lines), i + contextLines + 1)
|
||||
snippet = "\n".join(f"{j + 1}|{lines[j]}" for j in range(start, end))
|
||||
matches.append(snippet)
|
||||
|
||||
if not matches:
|
||||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=True,
|
||||
data=f"No matches for '{query}' in file.")
|
||||
|
||||
shown = matches[:20]
|
||||
resultText = f"Found {len(matches)} match(es) for '{query}':\n\n" + "\n---\n".join(shown)
|
||||
if len(matches) > 20:
|
||||
resultText += f"\n\n... and {len(matches) - 20} more matches"
|
||||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=True, data=resultText)
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="searchFiles", success=False, error=str(e))
|
||||
return ToolResult(toolCallId="", toolName="searchInFileContent", success=False, error=str(e))
|
||||
|
||||
async def _listFolders(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
try:
|
||||
|
|
@ -621,22 +682,63 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
return ToolResult(toolCallId="", toolName="createFolder", success=False, error=str(e))
|
||||
|
||||
async def _writeFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
name = args.get("name", "")
|
||||
content = args.get("content", "")
|
||||
if not name:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required")
|
||||
mode = args.get("mode", "create")
|
||||
fileId = args.get("fileId", "")
|
||||
name = args.get("name", "")
|
||||
|
||||
if not content:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="content is required")
|
||||
|
||||
try:
|
||||
chatService = services.chat
|
||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(
|
||||
content.encode("utf-8"), name
|
||||
)
|
||||
dbMgmt = chatService.interfaceDbComponent
|
||||
|
||||
if mode == "append":
|
||||
if not fileId:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=append")
|
||||
file = dbMgmt.getFile(fileId)
|
||||
if not file:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
|
||||
existingData = dbMgmt.getFileData(fileId) or b""
|
||||
try:
|
||||
existingText = existingData.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
existingText = existingData.decode("latin-1", errors="replace")
|
||||
newContent = existingText + content
|
||||
dbMgmt.updateFileData(fileId, newContent.encode("utf-8"))
|
||||
dbMgmt.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))})
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="writeFile", success=True,
|
||||
data=f"Appended {len(content)} chars to '{file.fileName}' (id: {fileId}, total: {len(newContent)} chars)",
|
||||
sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}],
|
||||
)
|
||||
|
||||
if mode == "overwrite":
|
||||
if not fileId:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=overwrite")
|
||||
file = dbMgmt.getFile(fileId)
|
||||
if not file:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found")
|
||||
dbMgmt.updateFileData(fileId, content.encode("utf-8"))
|
||||
dbMgmt.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))})
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="writeFile", success=True,
|
||||
data=f"Overwritten '{file.fileName}' (id: {fileId}, {len(content)} chars)",
|
||||
sideEvents=[{"type": "fileUpdated", "data": {"fileId": fileId, "fileName": file.fileName}}],
|
||||
)
|
||||
|
||||
# mode == "create" (default)
|
||||
if not name:
|
||||
return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create")
|
||||
fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name)
|
||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||
if fiId:
|
||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||||
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||||
if args.get("folderId"):
|
||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": args["folderId"]})
|
||||
dbMgmt.updateFile(fileItem.id, {"folderId": args["folderId"]})
|
||||
if args.get("tags"):
|
||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"tags": args["tags"]})
|
||||
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="writeFile", success=True,
|
||||
data=f"File '{name}' created (id: {fileItem.id})",
|
||||
|
|
@ -657,10 +759,18 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"readFile", _readFile,
|
||||
description="Read the content of a file by its fileId.",
|
||||
description=(
|
||||
"Read the content of a file. Returns full content by default. "
|
||||
"For large files, use offset and limit to read specific line ranges. "
|
||||
"When truncated, the response tells the total line count so you can paginate."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"fileId": {"type": "string", "description": "The file ID to read"}},
|
||||
"properties": {
|
||||
"fileId": {"type": "string", "description": "The file ID to read"},
|
||||
"offset": {"type": "integer", "description": "Start reading from this line number (1-based). Omit for full file."},
|
||||
"limit": {"type": "integer", "description": "Max number of lines to return (default: all). Use with offset for chunked reading."},
|
||||
},
|
||||
"required": ["fileId"]
|
||||
},
|
||||
readOnly=True
|
||||
|
|
@ -668,7 +778,10 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"listFiles", _listFiles,
|
||||
description="List LOCAL workspace files (uploaded/generated). NOT for external data sources -- use browseDataSource instead.",
|
||||
description=(
|
||||
"List files in the local workspace. Filter by folder, tags, or search term. "
|
||||
"For external data sources, use browseDataSource instead."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -681,22 +794,27 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
)
|
||||
|
||||
registry.register(
|
||||
"searchFiles", _searchFiles,
|
||||
description="Search LOCAL workspace files by name, description, or tags. NOT for external data sources -- use searchDataSource instead.",
|
||||
"searchInFileContent", _searchInFileContent,
|
||||
description=(
|
||||
"Search for text within a file's content. Returns matching lines with context. "
|
||||
"Case-insensitive. Use to locate specific text before using replaceInFile, "
|
||||
"or to find relevant sections in a large file before reading with offset/limit."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Additional tag filter"},
|
||||
"fileId": {"type": "string", "description": "The file ID to search in"},
|
||||
"query": {"type": "string", "description": "Text to search for (case-insensitive)"},
|
||||
"contextLines": {"type": "integer", "description": "Number of context lines around each match (default: 2)"},
|
||||
},
|
||||
"required": ["query"]
|
||||
"required": ["fileId", "query"]
|
||||
},
|
||||
readOnly=True
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"listFolders", _listFolders,
|
||||
description="List LOCAL workspace folders. NOT for external data sources -- use browseDataSource instead.",
|
||||
description="List folders in the local workspace. For external data sources, use browseDataSource instead.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -708,7 +826,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"webSearch", _webSearch,
|
||||
description="Search the web for information.",
|
||||
description="Search the web for general information. Use readUrl to fetch content from a known URL instead.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string", "description": "Search query"}},
|
||||
|
|
@ -719,7 +837,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"tagFile", _tagFile,
|
||||
description="Set tags on a file for categorization.",
|
||||
description="Set or update tags on a file for categorization and filtering via listFiles.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -733,7 +851,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"moveFile", _moveFile,
|
||||
description="Move a file to a different folder.",
|
||||
description="Move a file to a different folder in the local workspace.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -747,7 +865,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"createFolder", _createFolder,
|
||||
description="Create a new file folder.",
|
||||
description="Create a new folder in the local workspace.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -761,16 +879,24 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"writeFile", _writeFile,
|
||||
description="Create a new file with text content.",
|
||||
description=(
|
||||
"Create, append, or overwrite a file. Modes:\n"
|
||||
"- create (default): create a new file (name required).\n"
|
||||
"- append: append content to an existing file (fileId required). "
|
||||
"Use for large content that exceeds a single tool call (~8000 chars per call).\n"
|
||||
"- overwrite: replace entire file content (fileId required)."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "File name including extension"},
|
||||
"content": {"type": "string", "description": "File content as text"},
|
||||
"folderId": {"type": "string", "description": "Target folder ID"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags"},
|
||||
"name": {"type": "string", "description": "File name (required for mode=create)"},
|
||||
"content": {"type": "string", "description": "Content to write/append"},
|
||||
"mode": {"type": "string", "enum": ["create", "append", "overwrite"], "description": "Write mode (default: create)"},
|
||||
"fileId": {"type": "string", "description": "File ID (required for mode=append/overwrite)"},
|
||||
"folderId": {"type": "string", "description": "Target folder ID (mode=create only)"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags (mode=create only)"},
|
||||
},
|
||||
"required": ["name", "content"]
|
||||
"required": ["content"]
|
||||
},
|
||||
readOnly=False
|
||||
)
|
||||
|
|
@ -866,7 +992,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"deleteFile", _deleteFile,
|
||||
description="Delete a file from the workspace. Use when the user asks to remove or delete a file.",
|
||||
description="Permanently delete a file from the local workspace.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -879,7 +1005,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"renameFile", _renameFile,
|
||||
description="Rename a file in the workspace.",
|
||||
description="Rename a file in the local workspace. Include the file extension in the new name.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1000,34 +1126,51 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="copyFile", success=False, error=str(e))
|
||||
|
||||
async def _editFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
async def _replaceInFile(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
fileId = args.get("fileId", "")
|
||||
content = args.get("content", "")
|
||||
if not fileId or not content:
|
||||
return ToolResult(toolCallId="", toolName="editFile", success=False, error="fileId and content are required")
|
||||
oldText = args.get("oldText", "")
|
||||
newText = args.get("newText", "")
|
||||
replaceAll = args.get("replaceAll", False)
|
||||
if not fileId or not oldText:
|
||||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="fileId and oldText are required")
|
||||
try:
|
||||
chatService = services.chat
|
||||
dbMgmt = chatService.interfaceDbComponent
|
||||
file = dbMgmt.getFile(fileId)
|
||||
if not file:
|
||||
return ToolResult(toolCallId="", toolName="editFile", success=False, error=f"File {fileId} not found")
|
||||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=f"File {fileId} not found")
|
||||
if not dbMgmt.isTextMimeType(file.mimeType):
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="editFile", success=False,
|
||||
toolCallId="", toolName="replaceInFile", success=False,
|
||||
error=f"Cannot edit binary file ({file.mimeType}). Only text-based files are supported."
|
||||
)
|
||||
oldContent = ""
|
||||
oldData = dbMgmt.getFileData(fileId)
|
||||
if oldData:
|
||||
try:
|
||||
oldContent = oldData.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
oldContent = ""
|
||||
rawData = dbMgmt.getFileData(fileId)
|
||||
if not rawData:
|
||||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File has no content")
|
||||
try:
|
||||
oldContent = rawData.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File content is not valid UTF-8 text")
|
||||
|
||||
count = oldContent.count(oldText)
|
||||
if count == 0:
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="replaceInFile", success=False,
|
||||
error="oldText not found in file. Use readFile or searchInFileContent to verify the exact text."
|
||||
)
|
||||
if count > 1 and not replaceAll:
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="replaceInFile", success=False,
|
||||
error=f"oldText found {count} times. Set replaceAll=true or provide more surrounding context to make it unique."
|
||||
)
|
||||
|
||||
newContent = oldContent.replace(oldText, newText) if replaceAll else oldContent.replace(oldText, newText, 1)
|
||||
|
||||
editId = str(_uuid.uuid4())
|
||||
label = f"all {count} occurrences" if replaceAll else "1 occurrence"
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="editFile", success=True,
|
||||
data=f"Edit proposed for '{file.fileName}'. Waiting for user review.",
|
||||
toolCallId="", toolName="replaceInFile", success=True,
|
||||
data=f"Edit proposed for '{file.fileName}': replaced {label}. Waiting for user review.",
|
||||
sideEvents=[{
|
||||
"type": "fileEditProposal",
|
||||
"data": {
|
||||
|
|
@ -1036,16 +1179,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
"fileName": file.fileName,
|
||||
"mimeType": file.mimeType,
|
||||
"oldContent": oldContent,
|
||||
"newContent": content,
|
||||
"newContent": newContent,
|
||||
},
|
||||
}],
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="editFile", success=False, error=str(e))
|
||||
return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=str(e))
|
||||
|
||||
registry.register(
|
||||
"deleteFolder", _deleteFolder,
|
||||
description="Delete a folder. Set recursive=true to delete folder with all contents.",
|
||||
description="Delete a folder from the local workspace. Set recursive=true to delete all contents.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1059,7 +1202,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"renameFolder", _renameFolder,
|
||||
description="Rename a folder. Folder names must be unique within their parent.",
|
||||
description="Rename a folder in the local workspace.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1073,7 +1216,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"moveFolder", _moveFolder,
|
||||
description="Move a folder to a different parent folder. Cannot move a folder into its own subtree.",
|
||||
description="Move a folder to a different parent in the local workspace.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1087,7 +1230,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"copyFile", _copyFile,
|
||||
description="Create a full copy of a file. The copy is independent and can be edited separately.",
|
||||
description="Create an independent copy of a file in the local workspace.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1101,19 +1244,22 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
)
|
||||
|
||||
registry.register(
|
||||
"editFile", _editFile,
|
||||
"replaceInFile", _replaceInFile,
|
||||
description=(
|
||||
"Propose an edit to an existing text file. The change is shown to the user "
|
||||
"for review (accept/reject) before being applied. Only works for text-based "
|
||||
"files (text/*, application/json, etc.). For binary files, create a new file instead."
|
||||
"Replace specific text in an existing file. The edit is shown to the user for "
|
||||
"review (accept/reject) before being applied. Provide enough surrounding context "
|
||||
"in oldText to make the match unique (at least 2-3 lines). "
|
||||
"Use readFile or searchInFileContent first to identify the exact text to replace."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fileId": {"type": "string", "description": "The file ID to edit"},
|
||||
"content": {"type": "string", "description": "New file content (replaces entire file content)"},
|
||||
"oldText": {"type": "string", "description": "Exact text to find and replace (must be unique unless replaceAll=true)"},
|
||||
"newText": {"type": "string", "description": "The replacement text"},
|
||||
"replaceAll": {"type": "boolean", "description": "Replace all occurrences (default: false)"},
|
||||
},
|
||||
"required": ["fileId", "content"]
|
||||
"required": ["fileId", "oldText", "newText"]
|
||||
},
|
||||
readOnly=False
|
||||
)
|
||||
|
|
@ -1150,79 +1296,13 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))
|
||||
|
||||
async def _externalBrowse(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
connectionId = args.get("connectionId", "")
|
||||
service = args.get("service", "")
|
||||
path = args.get("path", "/")
|
||||
if not connectionId or not service:
|
||||
return ToolResult(toolCallId="", toolName="externalBrowse", success=False, error="connectionId and service are required")
|
||||
try:
|
||||
from modules.connectors.connectorResolver import ConnectorResolver
|
||||
resolver = ConnectorResolver(
|
||||
services.getService("security"),
|
||||
_buildResolverDb(),
|
||||
)
|
||||
adapter = await resolver.resolveService(connectionId, service)
|
||||
entries = await adapter.browse(path, filter=args.get("filter"))
|
||||
entryLines = "\n".join(
|
||||
f"- {'[DIR]' if e.isFolder else '[FILE]'} {e.name} ({e.size or '?'} bytes)"
|
||||
for e in entries
|
||||
) if entries else "Empty directory."
|
||||
return ToolResult(toolCallId="", toolName="externalBrowse", success=True, data=entryLines)
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="externalBrowse", success=False, error=str(e))
|
||||
|
||||
async def _externalDownload(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
connectionId = args.get("connectionId", "")
|
||||
service = args.get("service", "")
|
||||
path = args.get("path", "")
|
||||
if not connectionId or not service or not path:
|
||||
return ToolResult(toolCallId="", toolName="externalDownload", success=False, error="connectionId, service, and path are required")
|
||||
try:
|
||||
from modules.connectors.connectorResolver import ConnectorResolver
|
||||
from modules.connectors.connectorProviderBase import DownloadResult as _DR
|
||||
resolver = ConnectorResolver(
|
||||
services.getService("security"),
|
||||
_buildResolverDb(),
|
||||
)
|
||||
adapter = await resolver.resolveService(connectionId, service)
|
||||
result = await adapter.download(path)
|
||||
|
||||
if isinstance(result, _DR):
|
||||
fileBytes = result.data
|
||||
fileName = result.fileName or path.split("/")[-1] or "downloaded_file"
|
||||
else:
|
||||
fileBytes = result
|
||||
fileName = path.split("/")[-1] or "downloaded_file"
|
||||
|
||||
if not fileBytes:
|
||||
return ToolResult(toolCallId="", toolName="externalDownload", success=False, error="Download returned empty")
|
||||
|
||||
chatService = services.chat
|
||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
|
||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||
if fiId:
|
||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||
if tempFolderId:
|
||||
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
|
||||
ext = fileName.rsplit(".", 1)[-1].lower() if "." in fileName else ""
|
||||
hint = "Use readFile to read text content." if ext in ("doc", "docx", "txt", "csv", "json", "xml", "html", "md", "rtf", "odt", "xls", "xlsx", "pptx", "eml", "msg") else "Use readFile to access the content."
|
||||
return ToolResult(
|
||||
toolCallId="", toolName="externalDownload", success=True,
|
||||
data=f"Downloaded '{fileName}' ({len(fileBytes)} bytes) → local file id: {fid}. {hint}"
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="externalDownload", success=False, error=str(e))
|
||||
|
||||
async def _externalUpload(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
async def _uploadToExternal(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
connectionId = args.get("connectionId", "")
|
||||
service = args.get("service", "")
|
||||
path = args.get("path", "")
|
||||
fileId = args.get("fileId", "")
|
||||
if not connectionId or not service or not path or not fileId:
|
||||
return ToolResult(toolCallId="", toolName="externalUpload", success=False, error="connectionId, service, path, and fileId are required")
|
||||
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error="connectionId, service, path, and fileId are required")
|
||||
try:
|
||||
from modules.connectors.connectorResolver import ConnectorResolver
|
||||
resolver = ConnectorResolver(
|
||||
|
|
@ -1233,37 +1313,15 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
chatService = services.chat
|
||||
fileContent = chatService.getFileContent(fileId)
|
||||
if not fileContent:
|
||||
return ToolResult(toolCallId="", toolName="externalUpload", success=False, error="File not found")
|
||||
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error="File not found")
|
||||
fileData = fileContent.get("data", b"") if isinstance(fileContent, dict) else b""
|
||||
if isinstance(fileData, str):
|
||||
fileData = fileData.encode("utf-8")
|
||||
fileName = fileContent.get("fileName", "file") if isinstance(fileContent, dict) else "file"
|
||||
result = await adapter.upload(path, fileData, fileName)
|
||||
return ToolResult(toolCallId="", toolName="externalUpload", success=True, data=str(result))
|
||||
return ToolResult(toolCallId="", toolName="uploadToExternal", success=True, data=str(result))
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="externalUpload", success=False, error=str(e))
|
||||
|
||||
async def _externalSearch(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
connectionId = args.get("connectionId", "")
|
||||
service = args.get("service", "")
|
||||
query = args.get("query", "")
|
||||
if not connectionId or not service or not query:
|
||||
return ToolResult(toolCallId="", toolName="externalSearch", success=False, error="connectionId, service, and query are required")
|
||||
try:
|
||||
from modules.connectors.connectorResolver import ConnectorResolver
|
||||
resolver = ConnectorResolver(
|
||||
services.getService("security"),
|
||||
_buildResolverDb(),
|
||||
)
|
||||
adapter = await resolver.resolveService(connectionId, service)
|
||||
entries = await adapter.search(query, path=args.get("path"))
|
||||
resultLines = "\n".join(
|
||||
f"- {e.name} ({e.path})"
|
||||
for e in entries
|
||||
) if entries else "No results found."
|
||||
return ToolResult(toolCallId="", toolName="externalSearch", success=True, data=resultLines)
|
||||
except Exception as e:
|
||||
return ToolResult(toolCallId="", toolName="externalSearch", success=False, error=str(e))
|
||||
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e))
|
||||
|
||||
async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
connectionId = args.get("connectionId", "")
|
||||
|
|
@ -1293,48 +1351,22 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"listConnections", _listConnections,
|
||||
description="List available external connections and their services.",
|
||||
description="List the user's external connections (SharePoint, OneDrive, Outlook, etc.) and their IDs. Use with browseDataSource/uploadToExternal.",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
readOnly=True,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"externalBrowse", _externalBrowse,
|
||||
description="Browse files in an external source by connectionId+service. For ATTACHED data sources, prefer browseDataSource instead.",
|
||||
"uploadToExternal", _uploadToExternal,
|
||||
description=(
|
||||
"Upload a local file to an external storage via connectionId+service. "
|
||||
"Use listConnections to find available connections."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_connToolParams,
|
||||
"path": {"type": "string", "description": "Path to browse"},
|
||||
"filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
|
||||
},
|
||||
"required": ["connectionId", "service"],
|
||||
},
|
||||
readOnly=True,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"externalDownload", _externalDownload,
|
||||
description="Download a file from an external source into local storage + auto-index.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_connToolParams,
|
||||
"path": {"type": "string", "description": "File path to download"},
|
||||
},
|
||||
"required": ["connectionId", "service", "path"],
|
||||
},
|
||||
readOnly=False,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"externalUpload", _externalUpload,
|
||||
description="Upload a local file to an external data source.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_connToolParams,
|
||||
"path": {"type": "string", "description": "Destination path"},
|
||||
"path": {"type": "string", "description": "Destination path on the external service"},
|
||||
"fileId": {"type": "string", "description": "Local file ID to upload"},
|
||||
},
|
||||
"required": ["connectionId", "service", "path", "fileId"],
|
||||
|
|
@ -1342,24 +1374,9 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
readOnly=False,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"externalSearch", _externalSearch,
|
||||
description="Search files in an external source by connectionId+service. For ATTACHED data sources, prefer searchDataSource instead.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
**_connToolParams,
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
"path": {"type": "string", "description": "Scope to a specific path"},
|
||||
},
|
||||
"required": ["connectionId", "service", "query"],
|
||||
},
|
||||
readOnly=True,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"sendMail", _sendMail,
|
||||
description="Send an email via a connected mail service (Outlook, Gmail).",
|
||||
description="Send an email via a connected mail service (Outlook, Gmail). Use listConnections to find the connectionId.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1405,10 +1422,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
async def _browseDataSource(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
dsId = args.get("dataSourceId", "")
|
||||
subPath = args.get("subPath", "")
|
||||
if not dsId:
|
||||
return ToolResult(toolCallId="", toolName="browseDataSource", success=False, error="dataSourceId is required")
|
||||
directConnId = args.get("connectionId", "")
|
||||
directService = args.get("service", "")
|
||||
if not dsId and not (directConnId and directService):
|
||||
return ToolResult(toolCallId="", toolName="browseDataSource", success=False,
|
||||
error="Provide either dataSourceId OR connectionId+service")
|
||||
try:
|
||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||||
if dsId:
|
||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||||
else:
|
||||
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
||||
if subPath:
|
||||
if subPath.startswith("/"):
|
||||
browsePath = subPath
|
||||
|
|
@ -1439,11 +1462,19 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
async def _searchDataSource(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
dsId = args.get("dataSourceId", "")
|
||||
directConnId = args.get("connectionId", "")
|
||||
directService = args.get("service", "")
|
||||
query = args.get("query", "")
|
||||
if not dsId or not query:
|
||||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="dataSourceId and query are required")
|
||||
if not query:
|
||||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False, error="query is required")
|
||||
if not dsId and not (directConnId and directService):
|
||||
return ToolResult(toolCallId="", toolName="searchDataSource", success=False,
|
||||
error="Provide either dataSourceId OR connectionId+service")
|
||||
try:
|
||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||||
if dsId:
|
||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||||
else:
|
||||
connectionId, service, basePath = directConnId, directService, args.get("path", "/")
|
||||
from modules.connectors.connectorResolver import ConnectorResolver
|
||||
resolver = ConnectorResolver(
|
||||
services.getService("security"),
|
||||
|
|
@ -1463,14 +1494,22 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
async def _downloadFromDataSource(args: Dict[str, Any], context: Dict[str, Any]):
|
||||
dsId = args.get("dataSourceId", "")
|
||||
directConnId = args.get("connectionId", "")
|
||||
directService = args.get("service", "")
|
||||
filePath = args.get("filePath", "")
|
||||
fileName = args.get("fileName", "")
|
||||
if not dsId or not filePath:
|
||||
return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="dataSourceId and filePath are required")
|
||||
if not filePath:
|
||||
return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False, error="filePath is required")
|
||||
if not dsId and not (directConnId and directService):
|
||||
return ToolResult(toolCallId="", toolName="downloadFromDataSource", success=False,
|
||||
error="Provide either dataSourceId OR connectionId+service")
|
||||
try:
|
||||
from modules.connectors.connectorResolver import ConnectorResolver
|
||||
from modules.connectors.connectorProviderBase import DownloadResult as _DR
|
||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||||
if dsId:
|
||||
connectionId, service, basePath = await _resolveDataSource(dsId)
|
||||
else:
|
||||
connectionId, service, basePath = directConnId, directService, "/"
|
||||
fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}"
|
||||
resolver = ConnectorResolver(
|
||||
services.getService("security"),
|
||||
|
|
@ -1525,42 +1564,59 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"browseDataSource", _browseDataSource,
|
||||
description="Browse files AND folders in an ATTACHED data source by its dataSourceId. This is the PRIMARY tool for listing data source contents.",
|
||||
description=(
|
||||
"Browse files and folders in a data source. Accepts either:\n"
|
||||
"- dataSourceId (for attached data sources shown in the prompt), OR\n"
|
||||
"- connectionId + service (for direct connection access via listConnections)."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataSourceId": {"type": "string", "description": "DataSource ID (from the attached data sources in the prompt)"},
|
||||
"subPath": {"type": "string", "description": "Optional sub-path within the data source to browse"},
|
||||
"filter": {"type": "string", "description": "Optional filter pattern (e.g. '*.pdf')"},
|
||||
"dataSourceId": {"type": "string", "description": "DataSource ID (from attached data sources)"},
|
||||
"connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
|
||||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId, e.g. sharepoint, onedrive)"},
|
||||
"path": {"type": "string", "description": "Root path (used with connectionId+service)"},
|
||||
"subPath": {"type": "string", "description": "Sub-path within the data source to browse"},
|
||||
"filter": {"type": "string", "description": "Filter pattern (e.g. '*.pdf')"},
|
||||
},
|
||||
"required": ["dataSourceId"],
|
||||
},
|
||||
readOnly=True,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"searchDataSource", _searchDataSource,
|
||||
description="Search for files within an attached data source by query.",
|
||||
description=(
|
||||
"Search for files within a data source. Accepts either dataSourceId OR connectionId+service."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataSourceId": {"type": "string", "description": "DataSource ID"},
|
||||
"connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
|
||||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
|
||||
"path": {"type": "string", "description": "Scope path (used with connectionId+service)"},
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": ["dataSourceId", "query"],
|
||||
"required": ["query"],
|
||||
},
|
||||
readOnly=True,
|
||||
)
|
||||
|
||||
registry.register(
|
||||
"downloadFromDataSource", _downloadFromDataSource,
|
||||
description="Download a file or email message from an attached data source into local storage. Returns the local file ID which can then be read with readFile. For email sources (Outlook, Gmail), this downloads the full email content -- browse/search only return subjects. Always provide the fileName if known.",
|
||||
description=(
|
||||
"Download a file or email from a data source into local storage. Returns a local file ID "
|
||||
"to read with readFile. Accepts either dataSourceId OR connectionId+service. "
|
||||
"For email sources (Outlook, Gmail), browse/search only return subjects -- use this to get full content."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dataSourceId": {"type": "string", "description": "DataSource ID"},
|
||||
"filePath": {"type": "string", "description": "Path of the file to download (as returned by browseDataSource)"},
|
||||
"fileName": {"type": "string", "description": "Human-readable file name with extension (e.g. 'report.pdf'). Get this from browseDataSource results."},
|
||||
"connectionId": {"type": "string", "description": "UserConnection ID (alternative to dataSourceId)"},
|
||||
"service": {"type": "string", "description": "Service name (alternative to dataSourceId)"},
|
||||
"filePath": {"type": "string", "description": "Path of the file to download (from browseDataSource results)"},
|
||||
"fileName": {"type": "string", "description": "File name with extension (e.g. 'report.pdf')"},
|
||||
},
|
||||
"required": ["dataSourceId", "filePath"],
|
||||
},
|
||||
|
|
@ -1697,7 +1753,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"browseContainer", _browseContainer,
|
||||
description="Browse the structural index of a file/container (pages, sections, sheets, slides).",
|
||||
description="Browse the structural index of a document (pages, sections, sheets, slides). Use before readContentObjects for targeted reading.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"fileId": {"type": "string", "description": "The file ID to browse"}},
|
||||
|
|
@ -1708,7 +1764,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"readContentObjects", _readContentObjects,
|
||||
description="Read content objects from a file with optional filters (page, section, type).",
|
||||
description="Read extracted content objects from a file, optionally filtered by page, section, or type. Use browseContainer first to see the structure.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1724,7 +1780,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"extractContainerItem", _extractContainerItem,
|
||||
description="On-demand extraction of a specific item within a container (ZIP, nested file).",
|
||||
description="Extract a specific item from a container file (ZIP, nested file). Use browseContainer to see available items.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1738,7 +1794,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"summarizeContent", _summarizeContent,
|
||||
description="AI-powered summary of content objects from a file, optionally filtered.",
|
||||
description="Generate an AI-powered summary of a file's content. Optionally filter by section, page, or content type.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -1891,7 +1947,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"describeImage", _describeImage,
|
||||
description="Analyse an image using AI vision. Works with image files and images extracted from PDFs/DOCX/PPTX.",
|
||||
description="Analyze an image using AI vision. Works with image files and images extracted from PDFs/DOCX/PPTX. Use for OCR, data extraction, and visual analysis.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -2534,7 +2590,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"speechToText", _speechToText,
|
||||
description="Transcribe an audio file to text. Provide the fileId of an audio file from the workspace.",
|
||||
description="Transcribe an audio file to text using speech recognition. Returns the transcript with confidence score.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -2548,7 +2604,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"detectLanguage", _detectLanguage,
|
||||
description="Detect the language of a text.",
|
||||
description="Detect the language of a text snippet. Returns ISO 639-1 code (e.g. 'de', 'en').",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -2561,7 +2617,7 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
|||
|
||||
registry.register(
|
||||
"neutralizeData", _neutralizeData,
|
||||
description="Anonymize/neutralize text or file content. Replaces personal data (names, addresses, etc.) with placeholders. Does not modify the original.",
|
||||
description="Anonymize text or file content by replacing personal data (names, addresses, etc.) with placeholders. Non-destructive -- returns the anonymized copy.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue