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.
Files changed (67) hide show
  1. rossum_agent/__init__.py +9 -0
  2. rossum_agent/agent/__init__.py +32 -0
  3. rossum_agent/agent/core.py +932 -0
  4. rossum_agent/agent/memory.py +176 -0
  5. rossum_agent/agent/models.py +160 -0
  6. rossum_agent/agent/request_classifier.py +152 -0
  7. rossum_agent/agent/skills.py +132 -0
  8. rossum_agent/agent/types.py +5 -0
  9. rossum_agent/agent_logging.py +56 -0
  10. rossum_agent/api/__init__.py +1 -0
  11. rossum_agent/api/cli.py +51 -0
  12. rossum_agent/api/dependencies.py +190 -0
  13. rossum_agent/api/main.py +180 -0
  14. rossum_agent/api/models/__init__.py +1 -0
  15. rossum_agent/api/models/schemas.py +301 -0
  16. rossum_agent/api/routes/__init__.py +1 -0
  17. rossum_agent/api/routes/chats.py +95 -0
  18. rossum_agent/api/routes/files.py +113 -0
  19. rossum_agent/api/routes/health.py +44 -0
  20. rossum_agent/api/routes/messages.py +218 -0
  21. rossum_agent/api/services/__init__.py +1 -0
  22. rossum_agent/api/services/agent_service.py +451 -0
  23. rossum_agent/api/services/chat_service.py +197 -0
  24. rossum_agent/api/services/file_service.py +65 -0
  25. rossum_agent/assets/Primary_light_logo.png +0 -0
  26. rossum_agent/bedrock_client.py +64 -0
  27. rossum_agent/prompts/__init__.py +27 -0
  28. rossum_agent/prompts/base_prompt.py +80 -0
  29. rossum_agent/prompts/system_prompt.py +24 -0
  30. rossum_agent/py.typed +0 -0
  31. rossum_agent/redis_storage.py +482 -0
  32. rossum_agent/rossum_mcp_integration.py +123 -0
  33. rossum_agent/skills/hook-debugging.md +31 -0
  34. rossum_agent/skills/organization-setup.md +60 -0
  35. rossum_agent/skills/rossum-deployment.md +102 -0
  36. rossum_agent/skills/schema-patching.md +61 -0
  37. rossum_agent/skills/schema-pruning.md +23 -0
  38. rossum_agent/skills/ui-settings.md +45 -0
  39. rossum_agent/streamlit_app/__init__.py +1 -0
  40. rossum_agent/streamlit_app/app.py +646 -0
  41. rossum_agent/streamlit_app/beep_sound.py +36 -0
  42. rossum_agent/streamlit_app/cli.py +17 -0
  43. rossum_agent/streamlit_app/render_modules.py +123 -0
  44. rossum_agent/streamlit_app/response_formatting.py +305 -0
  45. rossum_agent/tools/__init__.py +214 -0
  46. rossum_agent/tools/core.py +173 -0
  47. rossum_agent/tools/deploy.py +404 -0
  48. rossum_agent/tools/dynamic_tools.py +365 -0
  49. rossum_agent/tools/file_tools.py +62 -0
  50. rossum_agent/tools/formula.py +187 -0
  51. rossum_agent/tools/skills.py +31 -0
  52. rossum_agent/tools/spawn_mcp.py +227 -0
  53. rossum_agent/tools/subagents/__init__.py +31 -0
  54. rossum_agent/tools/subagents/base.py +303 -0
  55. rossum_agent/tools/subagents/hook_debug.py +591 -0
  56. rossum_agent/tools/subagents/knowledge_base.py +305 -0
  57. rossum_agent/tools/subagents/mcp_helpers.py +47 -0
  58. rossum_agent/tools/subagents/schema_patching.py +471 -0
  59. rossum_agent/url_context.py +167 -0
  60. rossum_agent/user_detection.py +100 -0
  61. rossum_agent/utils.py +128 -0
  62. rossum_agent-1.0.0rc0.dist-info/METADATA +311 -0
  63. rossum_agent-1.0.0rc0.dist-info/RECORD +67 -0
  64. rossum_agent-1.0.0rc0.dist-info/WHEEL +5 -0
  65. rossum_agent-1.0.0rc0.dist-info/entry_points.txt +3 -0
  66. rossum_agent-1.0.0rc0.dist-info/licenses/LICENSE +21 -0
  67. 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})