rossum-agent 1.0.0rc0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rossum_agent/__init__.py +9 -0
- rossum_agent/agent/__init__.py +32 -0
- rossum_agent/agent/core.py +932 -0
- rossum_agent/agent/memory.py +176 -0
- rossum_agent/agent/models.py +160 -0
- rossum_agent/agent/request_classifier.py +152 -0
- rossum_agent/agent/skills.py +132 -0
- rossum_agent/agent/types.py +5 -0
- rossum_agent/agent_logging.py +56 -0
- rossum_agent/api/__init__.py +1 -0
- rossum_agent/api/cli.py +51 -0
- rossum_agent/api/dependencies.py +190 -0
- rossum_agent/api/main.py +180 -0
- rossum_agent/api/models/__init__.py +1 -0
- rossum_agent/api/models/schemas.py +301 -0
- rossum_agent/api/routes/__init__.py +1 -0
- rossum_agent/api/routes/chats.py +95 -0
- rossum_agent/api/routes/files.py +113 -0
- rossum_agent/api/routes/health.py +44 -0
- rossum_agent/api/routes/messages.py +218 -0
- rossum_agent/api/services/__init__.py +1 -0
- rossum_agent/api/services/agent_service.py +451 -0
- rossum_agent/api/services/chat_service.py +197 -0
- rossum_agent/api/services/file_service.py +65 -0
- rossum_agent/assets/Primary_light_logo.png +0 -0
- rossum_agent/bedrock_client.py +64 -0
- rossum_agent/prompts/__init__.py +27 -0
- rossum_agent/prompts/base_prompt.py +80 -0
- rossum_agent/prompts/system_prompt.py +24 -0
- rossum_agent/py.typed +0 -0
- rossum_agent/redis_storage.py +482 -0
- rossum_agent/rossum_mcp_integration.py +123 -0
- rossum_agent/skills/hook-debugging.md +31 -0
- rossum_agent/skills/organization-setup.md +60 -0
- rossum_agent/skills/rossum-deployment.md +102 -0
- rossum_agent/skills/schema-patching.md +61 -0
- rossum_agent/skills/schema-pruning.md +23 -0
- rossum_agent/skills/ui-settings.md +45 -0
- rossum_agent/streamlit_app/__init__.py +1 -0
- rossum_agent/streamlit_app/app.py +646 -0
- rossum_agent/streamlit_app/beep_sound.py +36 -0
- rossum_agent/streamlit_app/cli.py +17 -0
- rossum_agent/streamlit_app/render_modules.py +123 -0
- rossum_agent/streamlit_app/response_formatting.py +305 -0
- rossum_agent/tools/__init__.py +214 -0
- rossum_agent/tools/core.py +173 -0
- rossum_agent/tools/deploy.py +404 -0
- rossum_agent/tools/dynamic_tools.py +365 -0
- rossum_agent/tools/file_tools.py +62 -0
- rossum_agent/tools/formula.py +187 -0
- rossum_agent/tools/skills.py +31 -0
- rossum_agent/tools/spawn_mcp.py +227 -0
- rossum_agent/tools/subagents/__init__.py +31 -0
- rossum_agent/tools/subagents/base.py +303 -0
- rossum_agent/tools/subagents/hook_debug.py +591 -0
- rossum_agent/tools/subagents/knowledge_base.py +305 -0
- rossum_agent/tools/subagents/mcp_helpers.py +47 -0
- rossum_agent/tools/subagents/schema_patching.py +471 -0
- rossum_agent/url_context.py +167 -0
- rossum_agent/user_detection.py +100 -0
- rossum_agent/utils.py +128 -0
- rossum_agent-1.0.0rc0.dist-info/METADATA +311 -0
- rossum_agent-1.0.0rc0.dist-info/RECORD +67 -0
- rossum_agent-1.0.0rc0.dist-info/WHEEL +5 -0
- rossum_agent-1.0.0rc0.dist-info/entry_points.txt +3 -0
- rossum_agent-1.0.0rc0.dist-info/licenses/LICENSE +21 -0
- rossum_agent-1.0.0rc0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Dynamic tool loading for the Rossum Agent.
|
|
2
|
+
|
|
3
|
+
Provides functionality to load MCP tool categories on-demand to reduce context usage.
|
|
4
|
+
Catalog metadata is fetched from MCP server (single source of truth) and cached.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from rossum_agent.rossum_mcp_integration import mcp_tools_to_anthropic_format
|
|
17
|
+
from rossum_agent.tools.core import get_mcp_connection, get_mcp_event_loop
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from anthropic.types import ToolParam
|
|
21
|
+
from mcp.types import Tool as MCPTool
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CatalogData:
|
|
28
|
+
"""Cached catalog data from MCP server."""
|
|
29
|
+
|
|
30
|
+
catalog: dict[str, set[str]] = field(default_factory=dict)
|
|
31
|
+
keywords: dict[str, list[str]] = field(default_factory=dict)
|
|
32
|
+
destructive_tools: set[str] = field(default_factory=set)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Cached catalog from MCP (fetched once per process)
|
|
36
|
+
_catalog_cache: CatalogData | None = None
|
|
37
|
+
|
|
38
|
+
# Discovery tool that's always loaded
|
|
39
|
+
DISCOVERY_TOOL_NAME = "list_tool_categories"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class DynamicToolsState:
|
|
44
|
+
"""Mutable state container for dynamically loaded tools.
|
|
45
|
+
|
|
46
|
+
Stored on RossumAgent instance and passed to functions that need to modify
|
|
47
|
+
tool state. Using a class allows modifications in thread pool executors to
|
|
48
|
+
be visible in the main context (unlike context variables which are copied).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
loaded_categories: set[str] = field(default_factory=set)
|
|
52
|
+
tools: list[ToolParam] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def reset(self) -> None:
|
|
55
|
+
"""Reset state for a new conversation."""
|
|
56
|
+
self.loaded_categories.clear()
|
|
57
|
+
self.tools.clear()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Global state for backwards compatibility (used when agent instance not available)
|
|
61
|
+
_global_state: DynamicToolsState | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_global_state() -> DynamicToolsState:
|
|
65
|
+
"""Get or create global state for backwards compatibility."""
|
|
66
|
+
global _global_state
|
|
67
|
+
if _global_state is None:
|
|
68
|
+
_global_state = DynamicToolsState()
|
|
69
|
+
return _global_state
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def reset_dynamic_tools() -> None:
|
|
73
|
+
"""Reset dynamic tool state for a new conversation (global state)."""
|
|
74
|
+
get_global_state().reset()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_loaded_categories() -> set[str]:
|
|
78
|
+
"""Get the set of currently loaded categories (global state)."""
|
|
79
|
+
return get_global_state().loaded_categories
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_dynamic_tools() -> list[ToolParam]:
|
|
83
|
+
"""Get the list of dynamically loaded tools (global state)."""
|
|
84
|
+
return get_global_state().tools
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fetch_catalog_from_mcp() -> CatalogData:
|
|
88
|
+
"""Fetch tool catalog from MCP server."""
|
|
89
|
+
global _catalog_cache
|
|
90
|
+
|
|
91
|
+
if _catalog_cache is not None:
|
|
92
|
+
return _catalog_cache
|
|
93
|
+
|
|
94
|
+
mcp_connection = get_mcp_connection()
|
|
95
|
+
loop = get_mcp_event_loop()
|
|
96
|
+
|
|
97
|
+
if mcp_connection is None or loop is None:
|
|
98
|
+
logger.warning("MCP connection not available, returning empty catalog")
|
|
99
|
+
return CatalogData()
|
|
100
|
+
|
|
101
|
+
# Call list_tool_categories MCP tool to get catalog
|
|
102
|
+
try:
|
|
103
|
+
result = asyncio.run_coroutine_threadsafe(mcp_connection.call_tool("list_tool_categories", {}), loop).result(
|
|
104
|
+
timeout=10
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Handle various result formats from MCP
|
|
108
|
+
# 1. String (JSON) - parse it
|
|
109
|
+
if isinstance(result, str):
|
|
110
|
+
result = json.loads(result)
|
|
111
|
+
|
|
112
|
+
# 2. FastMCP wraps list returns in {"result": [...]}
|
|
113
|
+
if isinstance(result, dict) and "result" in result:
|
|
114
|
+
result = result["result"]
|
|
115
|
+
|
|
116
|
+
# 3. The unwrapped result might also be a JSON string
|
|
117
|
+
if isinstance(result, str):
|
|
118
|
+
result = json.loads(result)
|
|
119
|
+
|
|
120
|
+
catalog: dict[str, set[str]] = {}
|
|
121
|
+
keywords: dict[str, list[str]] = {}
|
|
122
|
+
destructive_tools: set[str] = set()
|
|
123
|
+
|
|
124
|
+
for category in result:
|
|
125
|
+
name = category["name"]
|
|
126
|
+
catalog[name] = {tool["name"] for tool in category["tools"]}
|
|
127
|
+
keywords[name] = category.get("keywords", [])
|
|
128
|
+
for tool in category["tools"]:
|
|
129
|
+
if tool.get("destructive", False):
|
|
130
|
+
destructive_tools.add(tool["name"])
|
|
131
|
+
|
|
132
|
+
_catalog_cache = CatalogData(catalog=catalog, keywords=keywords, destructive_tools=destructive_tools)
|
|
133
|
+
logger.info(f"Fetched catalog with {len(catalog)} categories from MCP")
|
|
134
|
+
return _catalog_cache
|
|
135
|
+
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.error(f"Failed to fetch catalog from MCP: {e}")
|
|
138
|
+
return CatalogData()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_category_tool_names() -> dict[str, set[str]]:
|
|
142
|
+
"""Get mapping of category names to tool names (fetched from MCP)."""
|
|
143
|
+
return _fetch_catalog_from_mcp().catalog
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_category_keywords() -> dict[str, list[str]]:
|
|
147
|
+
"""Get mapping of category names to keywords (fetched from MCP)."""
|
|
148
|
+
return _fetch_catalog_from_mcp().keywords
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_destructive_tools() -> set[str]:
|
|
152
|
+
"""Get set of destructive tool names (fetched from MCP)."""
|
|
153
|
+
return _fetch_catalog_from_mcp().destructive_tools
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def suggest_categories_for_request(request_text: str) -> list[str]:
|
|
157
|
+
"""Suggest tool categories based on keywords in the request.
|
|
158
|
+
|
|
159
|
+
Uses word boundary matching to avoid false positives (e.g., "credit" matching "edit").
|
|
160
|
+
"""
|
|
161
|
+
keywords = get_category_keywords()
|
|
162
|
+
if not keywords:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
request_lower = request_text.lower()
|
|
166
|
+
suggestions: list[str] = []
|
|
167
|
+
|
|
168
|
+
for category, category_keywords in keywords.items():
|
|
169
|
+
for keyword in category_keywords:
|
|
170
|
+
pattern = rf"\b{re.escape(keyword)}\b"
|
|
171
|
+
if re.search(pattern, request_lower):
|
|
172
|
+
suggestions.append(category)
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
return suggestions
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _filter_mcp_tools_by_names(mcp_tools: list[MCPTool], tool_names: set[str]) -> list[MCPTool]:
|
|
179
|
+
"""Filter MCP tools to only those with names in the given set."""
|
|
180
|
+
return [tool for tool in mcp_tools if tool.name in tool_names]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _filter_discovery_tools(mcp_tools: list[MCPTool]) -> list[MCPTool]:
|
|
184
|
+
"""Filter MCP tools to only discovery tools."""
|
|
185
|
+
return [tool for tool in mcp_tools if tool.name == DISCOVERY_TOOL_NAME]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _load_categories_impl(
|
|
189
|
+
categories: list[str],
|
|
190
|
+
state: DynamicToolsState | None = None,
|
|
191
|
+
exclude_destructive: bool = True,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Load multiple tool categories at once.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
categories: List of category names to load
|
|
197
|
+
state: Optional state container. Uses global state if not provided.
|
|
198
|
+
exclude_destructive: If True (default), skip destructive tools (delete operations).
|
|
199
|
+
Destructive tools can only be loaded via explicit load_tool call.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Message indicating which tools were loaded or an error message.
|
|
203
|
+
"""
|
|
204
|
+
if state is None:
|
|
205
|
+
state = get_global_state()
|
|
206
|
+
|
|
207
|
+
catalog = get_category_tool_names()
|
|
208
|
+
if not catalog:
|
|
209
|
+
return "Error: Could not fetch tool catalog from MCP"
|
|
210
|
+
|
|
211
|
+
valid_categories = set(catalog.keys())
|
|
212
|
+
invalid = [c for c in categories if c not in valid_categories]
|
|
213
|
+
if invalid:
|
|
214
|
+
return f"Error: Unknown categories {invalid}. Valid: {sorted(valid_categories)}"
|
|
215
|
+
|
|
216
|
+
to_load = [c for c in categories if c not in state.loaded_categories]
|
|
217
|
+
|
|
218
|
+
if not to_load:
|
|
219
|
+
return f"Categories already loaded: {categories}"
|
|
220
|
+
|
|
221
|
+
mcp_connection, loop = get_mcp_connection(), get_mcp_event_loop()
|
|
222
|
+
if mcp_connection is None or loop is None:
|
|
223
|
+
return "Error: MCP connection not available"
|
|
224
|
+
|
|
225
|
+
# Collect all tool names to load
|
|
226
|
+
tool_names_to_load: set[str] = set()
|
|
227
|
+
for category in to_load:
|
|
228
|
+
tool_names_to_load.update(catalog[category])
|
|
229
|
+
|
|
230
|
+
# Exclude destructive tools if requested (e.g., during automatic pre-loading)
|
|
231
|
+
if exclude_destructive:
|
|
232
|
+
destructive_tools = get_destructive_tools()
|
|
233
|
+
tool_names_to_load -= destructive_tools
|
|
234
|
+
|
|
235
|
+
# Get all MCP tools and filter
|
|
236
|
+
mcp_tools = asyncio.run_coroutine_threadsafe(mcp_connection.get_tools(), loop).result()
|
|
237
|
+
tools_to_add = _filter_mcp_tools_by_names(mcp_tools, tool_names_to_load)
|
|
238
|
+
|
|
239
|
+
if not tools_to_add:
|
|
240
|
+
return f"No tools found for categories: {to_load}"
|
|
241
|
+
|
|
242
|
+
# Convert to Anthropic format and add to dynamic tools
|
|
243
|
+
anthropic_tools = mcp_tools_to_anthropic_format(tools_to_add)
|
|
244
|
+
state.tools.extend(anthropic_tools)
|
|
245
|
+
|
|
246
|
+
# Mark categories as loaded
|
|
247
|
+
for category in to_load:
|
|
248
|
+
state.loaded_categories.add(category)
|
|
249
|
+
|
|
250
|
+
tool_names = [t.name for t in tools_to_add]
|
|
251
|
+
logger.info(f"Loaded {len(tool_names)} tools from categories {to_load}: {tool_names}")
|
|
252
|
+
|
|
253
|
+
return f"Loaded {len(tool_names)} tools from {to_load}: {', '.join(sorted(tool_names))}"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def preload_categories_for_request(request_text: str) -> str | None:
|
|
257
|
+
"""Pre-load tool categories based on keywords in the user's request.
|
|
258
|
+
|
|
259
|
+
Called automatically on first user message to reduce tool discovery friction.
|
|
260
|
+
Destructive tools (delete operations) are excluded - they can only be loaded
|
|
261
|
+
via explicit load_tool call.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Message about loaded categories, or None if nothing was loaded.
|
|
265
|
+
"""
|
|
266
|
+
suggestions = suggest_categories_for_request(request_text)
|
|
267
|
+
if not suggestions:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
result = _load_categories_impl(suggestions)
|
|
271
|
+
if result.startswith("Error") or result.startswith("Categories already"):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
logger.info(f"Pre-loaded categories based on request keywords: {suggestions}")
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_load_tool_category_definition() -> ToolParam:
|
|
279
|
+
"""Get the tool definition for load_tool_category."""
|
|
280
|
+
return {
|
|
281
|
+
"name": "load_tool_category",
|
|
282
|
+
"description": (
|
|
283
|
+
"Load MCP tools from one or more categories. Once loaded, the tools become "
|
|
284
|
+
"available for use. Use list_tool_categories first to see available categories.\n"
|
|
285
|
+
"Categories: annotations, queues, schemas, engines, hooks, email_templates, "
|
|
286
|
+
"document_relations, relations, rules, users, workspaces"
|
|
287
|
+
),
|
|
288
|
+
"input_schema": {
|
|
289
|
+
"type": "object",
|
|
290
|
+
"properties": {
|
|
291
|
+
"categories": {
|
|
292
|
+
"type": "array",
|
|
293
|
+
"items": {"type": "string"},
|
|
294
|
+
"description": "Category names to load (e.g., ['queues', 'schemas'])",
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
"required": ["categories"],
|
|
298
|
+
},
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load_tool_category(categories: list[str]) -> str:
|
|
303
|
+
"""Load MCP tools from specified categories."""
|
|
304
|
+
return _load_categories_impl(categories)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_load_tool_definition() -> ToolParam:
|
|
308
|
+
"""Get the tool definition for load_tool."""
|
|
309
|
+
return {
|
|
310
|
+
"name": "load_tool",
|
|
311
|
+
"description": (
|
|
312
|
+
"Load specific MCP tools by name. Use this to load destructive tools "
|
|
313
|
+
"(delete operations) which are excluded from load_tool_category. "
|
|
314
|
+
"Example: load_tool(['delete_hook']) to enable hook deletion."
|
|
315
|
+
),
|
|
316
|
+
"input_schema": {
|
|
317
|
+
"type": "object",
|
|
318
|
+
"properties": {
|
|
319
|
+
"tool_names": {
|
|
320
|
+
"type": "array",
|
|
321
|
+
"items": {"type": "string"},
|
|
322
|
+
"description": "Tool names to load (e.g., ['delete_hook', 'delete_queue'])",
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
"required": ["tool_names"],
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def load_tool(tool_names: list[str], state: DynamicToolsState | None = None) -> str:
|
|
331
|
+
"""Load specific MCP tools by name.
|
|
332
|
+
|
|
333
|
+
Use this to load destructive tools (delete operations) that are excluded
|
|
334
|
+
from category loading for safety.
|
|
335
|
+
"""
|
|
336
|
+
if state is None:
|
|
337
|
+
state = get_global_state()
|
|
338
|
+
|
|
339
|
+
mcp_connection, loop = get_mcp_connection(), get_mcp_event_loop()
|
|
340
|
+
if mcp_connection is None or loop is None:
|
|
341
|
+
return "Error: MCP connection not available"
|
|
342
|
+
|
|
343
|
+
# Get all MCP tools
|
|
344
|
+
mcp_tools = asyncio.run_coroutine_threadsafe(mcp_connection.get_tools(), loop).result()
|
|
345
|
+
available_tool_names = {t.name for t in mcp_tools}
|
|
346
|
+
|
|
347
|
+
# Validate requested tool names
|
|
348
|
+
invalid = [name for name in tool_names if name not in available_tool_names]
|
|
349
|
+
if invalid:
|
|
350
|
+
return f"Error: Unknown tools {invalid}"
|
|
351
|
+
|
|
352
|
+
# Filter to already-loaded tools
|
|
353
|
+
already_loaded = {t["name"] for t in state.tools}
|
|
354
|
+
to_load = [name for name in tool_names if name not in already_loaded]
|
|
355
|
+
|
|
356
|
+
if not to_load:
|
|
357
|
+
return f"Tools already loaded: {tool_names}"
|
|
358
|
+
|
|
359
|
+
# Filter MCP tools and convert to Anthropic format
|
|
360
|
+
tools_to_add = _filter_mcp_tools_by_names(mcp_tools, set(to_load))
|
|
361
|
+
anthropic_tools = mcp_tools_to_anthropic_format(tools_to_add)
|
|
362
|
+
state.tools.extend(anthropic_tools)
|
|
363
|
+
|
|
364
|
+
logger.info(f"Loaded {len(to_load)} tools by name: {to_load}")
|
|
365
|
+
return f"Loaded tools: {', '.join(sorted(to_load))}"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""File system tools for the Rossum Agent.
|
|
2
|
+
|
|
3
|
+
We use these tools to write into a specific output location.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from anthropic import beta_tool
|
|
13
|
+
|
|
14
|
+
from rossum_agent.tools.core import get_output_dir
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@beta_tool
|
|
20
|
+
def write_file(filename: str, content: str | dict | list) -> str:
|
|
21
|
+
"""Write content to a file in the agent's output directory.
|
|
22
|
+
|
|
23
|
+
Use this tool to save analysis results, export data, or create reports.
|
|
24
|
+
Files are saved to a session-specific directory that can be shared with the user.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
filename: The name of the file to write (e.g., 'report.md', 'analysis.json').
|
|
28
|
+
content: The content to write to the file. Can be a string, dict, or list.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
JSON with status, message, and file path.
|
|
32
|
+
"""
|
|
33
|
+
if not filename or not filename.strip():
|
|
34
|
+
return json.dumps({"status": "error", "message": "Error: filename is required"})
|
|
35
|
+
|
|
36
|
+
if not content:
|
|
37
|
+
return json.dumps({"status": "error", "message": "Error: content is required"})
|
|
38
|
+
|
|
39
|
+
# Convert dict/list to JSON string
|
|
40
|
+
if isinstance(content, dict | list):
|
|
41
|
+
content = json.dumps(content, indent=2, ensure_ascii=False)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
output_dir = get_output_dir()
|
|
45
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
safe_filename = Path(filename).name
|
|
48
|
+
file_path = output_dir / safe_filename
|
|
49
|
+
|
|
50
|
+
file_path.write_text(content, encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
logger.info(f"write_file: wrote {len(content)} chars to {file_path}")
|
|
53
|
+
return json.dumps(
|
|
54
|
+
{
|
|
55
|
+
"status": "success",
|
|
56
|
+
"message": f"Successfully wrote {len(content)} characters to {safe_filename}",
|
|
57
|
+
"path": str(file_path),
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.exception("Error in write_file")
|
|
62
|
+
return json.dumps({"status": "error", "message": f"Error writing file: {e}"})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Formula field suggestion tool for the Rossum Agent.
|
|
2
|
+
|
|
3
|
+
This module provides a tool to get formula suggestions from Rossum's internal API
|
|
4
|
+
for formula fields based on natural language descriptions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from anthropic import beta_tool
|
|
16
|
+
|
|
17
|
+
from rossum_agent.tools.core import require_rossum_credentials
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_SUGGEST_FORMULA_TIMEOUT = 60
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_suggest_formula_url(api_base_url: str) -> str:
|
|
25
|
+
"""Build the suggest_formula endpoint URL.
|
|
26
|
+
|
|
27
|
+
Uses the base URL directly (e.g., https://elis.rossum.ai/api/v1)
|
|
28
|
+
and appends the internal endpoint path.
|
|
29
|
+
"""
|
|
30
|
+
return f"{api_base_url.rstrip('/')}/internal/schemas/suggest_formula"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fetch_schema_content(api_base_url: str, token: str, schema_id: int) -> list[dict]:
|
|
34
|
+
"""Fetch schema content from Rossum API."""
|
|
35
|
+
url = f"{api_base_url.rstrip('/')}/schemas/{schema_id}"
|
|
36
|
+
with httpx.Client(timeout=30) as client:
|
|
37
|
+
response = client.get(url, headers={"Authorization": f"Bearer {token}"})
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
return response.json()["content"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _create_formula_field_definition(label: str, field_schema_id: str | None = None) -> dict:
|
|
43
|
+
"""Create a properly structured formula field definition."""
|
|
44
|
+
if not field_schema_id:
|
|
45
|
+
field_schema_id = label.lower().replace(" ", "_")
|
|
46
|
+
return {
|
|
47
|
+
"id": field_schema_id,
|
|
48
|
+
"label": label,
|
|
49
|
+
"type": "string",
|
|
50
|
+
"category": "datapoint",
|
|
51
|
+
"can_export": True,
|
|
52
|
+
"constraints": {"required": False},
|
|
53
|
+
"disable_prediction": False,
|
|
54
|
+
"formula": "",
|
|
55
|
+
"hidden": False,
|
|
56
|
+
"rir_field_names": [],
|
|
57
|
+
"score_threshold": 0,
|
|
58
|
+
"suggest": True,
|
|
59
|
+
"ui_configuration": {"type": "formula", "edit": "disabled"},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _find_field_in_schema(nodes: list[dict], field_id: str) -> bool:
|
|
64
|
+
"""Recursively search for a field ID in schema content."""
|
|
65
|
+
for node in nodes:
|
|
66
|
+
if node.get("id") == field_id:
|
|
67
|
+
return True
|
|
68
|
+
if "children" in node:
|
|
69
|
+
children = node["children"]
|
|
70
|
+
if isinstance(children, list) and _find_field_in_schema(children, field_id):
|
|
71
|
+
return True
|
|
72
|
+
if isinstance(children, dict) and _find_field_in_schema([children], field_id):
|
|
73
|
+
return True
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _inject_formula_field(
|
|
78
|
+
schema_content: list[dict], label: str, section_id: str, field_schema_id: str | None = None
|
|
79
|
+
) -> list[dict]:
|
|
80
|
+
"""Inject a formula field into the specified section of schema_content.
|
|
81
|
+
|
|
82
|
+
The suggest_formula API requires the target field to exist in schema_content.
|
|
83
|
+
"""
|
|
84
|
+
if not field_schema_id:
|
|
85
|
+
field_schema_id = label.lower().replace(" ", "_")
|
|
86
|
+
|
|
87
|
+
if _find_field_in_schema(schema_content, field_schema_id):
|
|
88
|
+
return schema_content
|
|
89
|
+
|
|
90
|
+
modified = copy.deepcopy(schema_content)
|
|
91
|
+
formula_field = _create_formula_field_definition(label, field_schema_id)
|
|
92
|
+
|
|
93
|
+
for section in modified:
|
|
94
|
+
if section.get("id") == section_id and section.get("category") == "section":
|
|
95
|
+
section.setdefault("children", []).append(formula_field)
|
|
96
|
+
return modified
|
|
97
|
+
|
|
98
|
+
if modified and modified[0].get("category") == "section":
|
|
99
|
+
modified[0].setdefault("children", []).append(formula_field)
|
|
100
|
+
else:
|
|
101
|
+
modified.append(formula_field)
|
|
102
|
+
|
|
103
|
+
return modified
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@beta_tool
|
|
107
|
+
def suggest_formula_field(
|
|
108
|
+
label: str, hint: str, schema_id: int, section_id: str, field_schema_id: str | None = None
|
|
109
|
+
) -> str:
|
|
110
|
+
"""Get AI-generated formula suggestions for a new formula field.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
label: Display label for the field (e.g., 'Net Terms').
|
|
114
|
+
hint: Natural language description of the formula logic.
|
|
115
|
+
schema_id: The numeric schema ID (e.g., 9389721). Get this from get_schema or list_queues.
|
|
116
|
+
section_id: Section ID where the field belongs. Ask the user if not specified.
|
|
117
|
+
field_schema_id: Optional ID for the formula field. Defaults to label.lower().replace(" ", "_").
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
JSON with formula suggestion and field_definition for use with patch_schema.
|
|
121
|
+
"""
|
|
122
|
+
field_schema_id = field_schema_id or label.lower().replace(" ", "_")
|
|
123
|
+
logger.info(f"suggest_formula_field: {field_schema_id=}, {schema_id=}, {section_id=}, hint={hint[:100]}...")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
api_base_url, token = require_rossum_credentials()
|
|
127
|
+
url = _build_suggest_formula_url(api_base_url)
|
|
128
|
+
|
|
129
|
+
schema_content = _fetch_schema_content(api_base_url, token, schema_id)
|
|
130
|
+
enriched_schema = _inject_formula_field(schema_content, label, section_id, field_schema_id)
|
|
131
|
+
|
|
132
|
+
payload = {"field_schema_id": field_schema_id, "hint": hint, "schema_content": enriched_schema}
|
|
133
|
+
|
|
134
|
+
logger.debug(f"Calling suggest_formula API: {url}")
|
|
135
|
+
logger.debug(f"suggest_formula payload: {json.dumps(payload, indent=2)}")
|
|
136
|
+
|
|
137
|
+
with httpx.Client(timeout=_SUGGEST_FORMULA_TIMEOUT) as client:
|
|
138
|
+
response = client.post(
|
|
139
|
+
url,
|
|
140
|
+
json=payload,
|
|
141
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
142
|
+
)
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
result = response.json()
|
|
145
|
+
|
|
146
|
+
suggestions = result.get("results", [])
|
|
147
|
+
if not suggestions:
|
|
148
|
+
return json.dumps(
|
|
149
|
+
{"status": "no_suggestions", "message": "No formula suggestions returned. Try rephrasing the hint."}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
top_suggestion = suggestions[0]
|
|
153
|
+
formula = top_suggestion.get("formula", "")
|
|
154
|
+
summary = top_suggestion.get("summary", "")
|
|
155
|
+
if summary:
|
|
156
|
+
summary = _clean_html(summary)
|
|
157
|
+
|
|
158
|
+
field_definition = _create_formula_field_definition(label, field_schema_id)
|
|
159
|
+
field_definition["formula"] = formula
|
|
160
|
+
|
|
161
|
+
return json.dumps(
|
|
162
|
+
{
|
|
163
|
+
"status": "success",
|
|
164
|
+
"formula": formula,
|
|
165
|
+
"field_definition": field_definition,
|
|
166
|
+
"section_id": section_id,
|
|
167
|
+
"summary": summary,
|
|
168
|
+
"description": _clean_html(top_suggestion.get("description", "")),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
except httpx.HTTPStatusError as e:
|
|
173
|
+
logger.exception("HTTP error in suggest_formula_field")
|
|
174
|
+
return json.dumps(
|
|
175
|
+
{
|
|
176
|
+
"status": "error",
|
|
177
|
+
"error": f"HTTP {e.response.status_code}: {e.response.text[:500]}",
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.exception("Error in suggest_formula_field")
|
|
182
|
+
return json.dumps({"status": "error", "error": str(e)})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _clean_html(text: str) -> str:
|
|
186
|
+
"""Remove HTML tags from text (simple cleanup for display)."""
|
|
187
|
+
return re.sub(r"<[^>]+>", "", text)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from anthropic import beta_tool
|
|
7
|
+
|
|
8
|
+
from rossum_agent.agent.skills import get_skill, get_skill_registry
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@beta_tool
|
|
14
|
+
def load_skill(name: str) -> str:
|
|
15
|
+
"""Load a specialized skill that provides domain-specific instructions and workflows.
|
|
16
|
+
|
|
17
|
+
Use this tool when you recognize that a task matches one of the available skills.
|
|
18
|
+
The skill will provide detailed instructions, workflows, and context for the task.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
name: The name of the skill to load (e.g., "rossum-deployment").
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
JSON with skill instructions, or error with available skills if not found.
|
|
25
|
+
"""
|
|
26
|
+
if (skill := get_skill(name)) is None:
|
|
27
|
+
available = get_skill_registry().get_skill_names()
|
|
28
|
+
logger.error(f"Skill '{name}' not found. Available skills: {available}")
|
|
29
|
+
return json.dumps({"status": "error", "message": f"Skill '{name}' not found.", "available_skills": available})
|
|
30
|
+
logger.info(f"Loaded skill '{skill.name}'")
|
|
31
|
+
return json.dumps({"status": "success", "skill_name": skill.name, "instructions": skill.content})
|