How agent behaviour is assembled: skills & tools
The system prompt is deliberately thin on procedure — it defers to skills and a tool registry. Trace how a SKILL.md becomes a loaded procedure and how the tool pool is built and scoped.
backend/anylegal_oss/workspace/tools/workspace_tools.py1183 lines · get_workspace_tools L1160–1162
Outline 4 symbols
- get_workspace_tools function
- get_tool_schema function
- get_tools_for_skill function
- get_all_tool_names function
1"""
2Workspace Tool Definitions
3
4Defines the tools available to the agentic workspace system.
5Tool schemas are in Anthropic function calling format (input_schema).
6
7Categories:
8- Document Management (6): list_documents, read_document, create_document, create_folder, delete_document, delete_folder
9- Web/Research (2): web_search, web_fetch
10- Code Execution (1): run_code
11- DOCX Editing (12): edit_document, clone_document, add_comment, revert_edit, get_revision_stats, accept_all_changes, reject_all_changes, accept_changes, reject_changes, instantiate_template, produce_redline
12- Comparison (1): compare
13- Skills/Modes (3): Skill, enter_plan_mode, exit_plan_mode
14- Progress (1): todo_write
15"""
16
17import os
18from typing import List, Dict, Any, Optional
19
20# Plan mode (model-initiated multi-step planner) is opt-in in OSS — see
21# `.env.example` and the ANYLEGAL_PLANNER_MODE comment. When disabled,
22# enter_plan_mode / exit_plan_mode are not surfaced to the LLM at all,
23# so the model can never invoke the plan-approval flow.
24_PLANNER_ENABLED: bool = os.getenv("ANYLEGAL_PLANNER_MODE", "disabled").lower() == "enabled"
25
26LIST_DOCUMENTS_TOOL = {
27 "name": "list_documents",
28 "description": "List all files in the workspace: uploaded documents, workspace files (anylegal.md, Playbook/positions.md), skills (read-only), and templates (user-uploaded, read-only for agent). Optionally filter by folder.",
29 "input_schema": {
30 "type": "object",
31 "properties": {
32 "folder": {
33 "type": "string",
34 "description": "Optional folder path to filter by (e.g., 'Clients/Acme/'). If omitted, lists all files."
35 }
36 },
37 "required": []
38 }
39}
40
41READ_DOCUMENT_TOOL = {
42 "name": "read_document",
43 "description": (
44 "Read the content of any file in the workspace: uploaded documents (DOCX, XLSX, PPTX, PDF), workspace files "
45 "(anylegal.md, Playbook/positions.md), skills (Skills/*/SKILL.md), or templates (Templates/*.docx). "
46 "XLSX files return sheet data as markdown tables. PPTX files return slide text and speaker notes. "
47 "For DOCX files, use view='text' (default) for plain text. Use view='xml' only for debugging.\n\n"
48 "**Range params (post-edit verification):** after a batch of edits, use `around_text=` "
49 "to read the affected region without pulling the whole document. Three modes (mutually "
50 "exclusive — at most one):\n"
51 "- `around_text='clause text'`: returns ±context_chars/2 around the first match.\n"
52 "- `start_text='8.', end_text='9.'`: returns content between two anchors (inclusive).\n"
53 "- `paragraph_range=[20, 35]`: returns paragraphs 20-35 (inclusive, 0-indexed).\n"
54 "Range params apply to text view only; ignored for view='xml'."
55 ),
56 "input_schema": {
57 "type": "object",
58 "properties": {
59 "path": {
60 "type": "string",
61 "description": "File path — document UUID for uploads, or workspace path (e.g., 'anylegal.md', 'Playbook/positions.md', 'Skills/review/SKILL.md', 'Templates/NDA_Template.docx')"
62 },
63 "view": {
64 "type": "string",
65 "enum": ["text", "xml"],
66 "description": (
67 "For DOCX: 'text' (default) returns plain text for reading and editing; "
68 "'xml' returns the raw document.xml (advanced debugging only). "
69 "Ignored for non-DOCX documents."
70 ),
71 "default": "text"
72 },
73 "around_text": {
74 "type": "string",
75 "description": (
76 "Slice the document around the first occurrence of this text. "
77 "Returns context_chars total (centered on the match). "
78 "Use after edits to verify a specific region."
79 ),
80 },
81 "context_chars": {
82 "type": "integer",
83 "description": "Total character window for around_text mode. Default 2000.",
84 "default": 2000,
85 },
86 "start_text": {
87 "type": "string",
88 "description": (
89 "Start anchor for explicit-range mode. Returns content from this anchor "
90 "to end_text (or EOF if end_text not provided / not found)."
91 ),
92 },
93 "end_text": {
94 "type": "string",
95 "description": "End anchor for explicit-range mode (used with start_text).",
96 },
97 "paragraph_range": {
98 "type": "array",
99 "items": {"type": "integer"},
100 "minItems": 2,
101 "maxItems": 2,
102 "description": (
103 "[start_idx, end_idx] paragraph slice (inclusive, 0-indexed). "
104 "Out-of-bounds end clamps to last paragraph."
105 ),
106 },
107 },
108 "required": ["path"]
109 }
110}
111
112CREATE_DOCUMENT_TOOL = {
113 "name": "create_document",
114 "description": (
115 "Create or update workspace text files: anylegal.md (instructions) and Playbook/*.md (negotiation positions). "
116 "NOT for DOCX documents — use run_python with python-docx for all document creation. "
117 "Skills/ and Templates/ are read-only."
118 ),
119 "input_schema": {
120 "type": "object",
121 "properties": {
122 "path": {
123 "type": "string",
124 "description": (
125 "Workspace file path: 'anylegal.md', 'Playbook/commercial-contracts.md', etc. "
126 "Do NOT use for .docx paths — use run_python instead."
127 )
128 },
129 "content": {
130 "type": "string",
131 "description": "File content in markdown."
132 },
133 "description": {
134 "type": "string",
135 "description": "Brief description of the file"
136 }
137 },
138 "required": ["path", "content"]
139 }
140}
141
142CREATE_FOLDER_TOOL = {
143 "name": "create_folder",
144 "description": (
145 "Create a folder in the user's workspace. "
146 "Use during /setup to scaffold a folder structure suited to the user's profile. "
147 "Creates all intermediate parent folders automatically. "
148 "Skills/ and Templates/ are system folders and cannot be modified."
149 ),
150 "input_schema": {
151 "type": "object",
152 "properties": {
153 "folder_path": {
154 "type": "string",
155 "description": "Folder path to create, e.g. 'Clients/' or 'Contracts/NDAs/'. Trailing slash optional. Parent folders are created automatically."
156 }
157 },
158 "required": ["folder_path"]
159 }
160}
161
162DELETE_DOCUMENT_TOOL = {
163 "name": "delete_document",
164 "description": (
165 "Delete a single document or workspace file. "
166 "Use during /setup 'start fresh' to remove old files the user no longer wants. "
167 "Always confirm with the user which specific files to delete before calling this. "
168 "Cannot delete system files in Skills/ or Templates/."
169 ),
170 "input_schema": {
171 "type": "object",
172 "properties": {
173 "path": {
174 "type": "string",
175 "description": "Path of the file to delete, e.g. 'Playbook/positions.md' or 'Clients/contract.docx'."
176 }
177 },
178 "required": ["path"]
179 }
180}
181
182DELETE_FOLDER_TOOL = {
183 "name": "delete_folder",
184 "description": (
185 "Delete a user folder and all its contents. "
186 "Use during /setup 'start fresh' to clear old folder structure. "
187 "Always confirm with the user exactly which folders to delete before calling this. "
188 "Protected system folders (Skills/, Templates/) cannot be deleted."
189 ),
190 "input_schema": {
191 "type": "object",
192 "properties": {
193 "folder_path": {
194 "type": "string",
195 "description": "Folder path to delete, e.g. 'Clients/' or 'OldContracts/'. All files inside will be removed."
196 }
197 },
198 "required": ["folder_path"]
199 }
200}
201
202WEB_SEARCH_TOOL = {
203 "name": "web_search",
204 "description": (
205 "Search the web for market standards, legal precedents, regulatory requirements, "
206 "or current practices. Use when the user asks about 'market standard', 'typical "
207 "terms', 'best practice', or needs external research.\n\n"
208 "**Always pass `jurisdiction`** so results are localized (UK, US, Singapore, UAE, "
209 "etc.). If the jurisdiction is ambiguous from context, ask the user before "
210 "searching — don't guess. Prefer official government and institutional sources "
211 "over secondary commentary when citing results.\n\n"
212 "**If a search returns no relevant results, say so explicitly** — do NOT "
213 "fabricate citations to fill the gap."
214 ),
215 "input_schema": {
216 "type": "object",
217 "properties": {
218 "query": {
219 "type": "string",
220 "description": "Search query - be specific and include relevant legal/business context"
221 },
222 "jurisdiction": {
223 "type": "string",
224 "description": (
225 "Relevant jurisdiction for the search (e.g., 'UK', 'US', 'SG', "
226 "'Singapore', 'GENERAL'). Always provide — unlocalized results are "
227 "rarely useful for legal research."
228 )
229 },
230 "count": {
231 "type": "integer",
232 "description": "Number of results to return (1-10)",
233 "default": 5,
234 "minimum": 1,
235 "maximum": 10
236 }
237 },
238 "required": ["query"]
239 }
240}
241
242WEB_FETCH_TOOL = {
243 "name": "web_fetch",
244 "description": "Fetch and extract content from a URL. Supports HTML pages and PDF documents. Use for reading legal articles, statutes, regulations, court filings, or any web page/PDF that contains relevant information.",
245 "input_schema": {
246 "type": "object",
247 "properties": {
248 "url": {
249 "type": "string",
250 "description": "Full URL to fetch (must start with http:// or https://)"
251 },
252 "extract_mode": {
253 "type": "string",
254 "enum": ["markdown", "text"],
255 "description": "How to extract content - 'markdown' preserves formatting, 'text' is plain text",
256 "default": "markdown"
257 },
258 "max_chars": {
259 "type": "integer",
260 "description": "Maximum characters to return (truncates if exceeded)",
261 "default": 50000
262 }
263 },
264 "required": ["url"]
265 }
266}
267
268COMPARE_TOOL = {
269 "name": "compare",
270 "description": "Compare two texts or two session documents. Returns structured diff, similarity percentage, and visual output. Use for redline analysis, version comparison, or counterparty revision review.",
271 "input_schema": {
272 "type": "object",
273 "properties": {
274 "text_a": {
275 "type": "string",
276 "description": "First text to compare (or omit if using path_a)"
277 },
278 "text_b": {
279 "type": "string",
280 "description": "Second text to compare (or omit if using path_b)"
281 },
282 "path_a": {
283 "type": "string",
284 "description": "Path to first document in session (alternative to text_a)"
285 },
286 "path_b": {
287 "type": "string",
288 "description": "Path to second document in session (alternative to text_b)"
289 },
290 "format": {
291 "type": "string",
292 "enum": ["summary", "html", "markdown"],
293 "description": "Output format",
294 "default": "summary"
295 }
296 },
297 "required": []
298 }
299}
300
301RUN_CODE_TOOL = {
302 "name": "run_code",
303 "description": (
304 "Execute code in an isolated sandbox with pre-installed libraries. Supports "
305 "Python 3.11 (default) and Node.js 20 — select via the `language` parameter.\n\n"
306 "Python use cases: Excel read/edit/create (openpyxl), PowerPoint (python-pptx), "
307 "data analysis (pandas), date/financial calculations, PDF text extraction "
308 "(pymupdf4llm), charts (matplotlib), bulk OOXML edits via lxml+zipfile, any "
309 "programmatic logic.\n\n"
310 "Node use cases: DOCX creation with docx-js — the ONLY supported path for new "
311 "Word documents. Gives you native footnotes (FootnoteReferenceRun), internal "
312 "hyperlinks (Bookmark + InternalHyperlink), TableOfContents, PageNumber in "
313 "headers/footers, positional tabs with leaders, multi-column sections.\n\n"
314 "**File-type conventions:**\n"
315 "- **DOCX creation (new document from scratch):** docx-js via `language=\"node\"` "
316 "ONLY. python-docx is NOT supported for from-scratch creation — it has no API for "
317 "footnotes/TOC/page-numbers and produces documents Word flags as corrupt. "
318 "The `draft` skill has canonical examples.\n"
319 "- **DOCX editing (existing document, including filling template placeholders):** "
320 "use `edit_document` — not `run_code`. The `docx-editing` skill covers template "
321 "fill and redlining.\n"
322 "- **Spreadsheets (.xlsx):** first check `Templates/` for a matching template; "
323 "if one exists, open and fill it. Always use Excel FORMULAS (`=SUM(...)`, "
324 "`=VLOOKUP(...)`) — never hardcode calculated values. Save to "
325 "`/sandbox/output/filename.xlsx` (auto-imports with PDF preview). Do NOT create "
326 "HTML versions — .xlsx is previewed natively.\n"
327 "- **Presentations (.pptx):** first check `Templates/` for a matching template "
328 "to preserve slide master / layouts / branding. Save to `/sandbox/output/filename.pptx`. "
329 "Do NOT create HTML versions.\n\n"
330 "**ONE DOCUMENT = ONE `run_code` CALL** for single-doc creation — never split a "
331 "document across multiple scripts. For documents too large for one call, see the "
332 "long-document pattern in the `draft` skill (incremental builds via input_files).\n\n"
333 "**Citation numbering in DOCX outputs:** when the script writes a Sources or "
334 "References section, number it from 1 and match the inline `[1]`, `[2]`, `[3]` "
335 "order in the body. NEVER continue numbering from a previous chat message — "
336 "each document is its own numbering scope, starts fresh at 1.\n\n"
337 "IMPORTANT: Each run is an EPHEMERAL Docker container — files from previous runs do NOT persist. "
338 "To modify a document created by a previous run, pass it via input_files so it is mounted at /sandbox/input/<filename>. "
339 "NEVER reference /sandbox/output/ paths from a previous run — the file will not exist. "
340 "`.doc` files passed via input_files are auto-converted to `.docx`. "
341 "Input workspace files at /sandbox/input/<filename>. "
342 "Write output files to /sandbox/output/<filename> — they are automatically added to the workspace. "
343 "DOCX outputs are validated automatically (XSD + rels + tracked-change checks). If "
344 "`validation_errors` appears in the result, fix the code and retry (max 3 attempts). "
345 "`validation_warnings` are informational only — do NOT retry to fix them. "
346 "No network access. 120-second timeout. 512MB memory limit."
347 ),
348 "input_schema": {
349 "type": "object",
350 "properties": {
351 "code": {
352 "type": "string",
353 "description": (
354 "Source code to execute. Must be self-contained. "
355 "Read input files from /sandbox/input/<filename>. "
356 "Write output files to /sandbox/output/<filename>. "
357 "Use print() (Python) or console.log() (Node) for diagnostic output — "
358 "captured in stdout.\n\n"
359 "Python pre-installed: python-docx, python-pptx, openpyxl, pandas, lxml, "
360 "matplotlib, reportlab, pymupdf4llm, python-dateutil, Pillow, tabulate, regex.\n\n"
361 "Node pre-installed: docx (docx-js 9.5.1). Other packages not available — "
362 "no network, no npm install at runtime."
363 )
364 },
365 "language": {
366 "type": "string",
367 "enum": ["python", "node"],
368 "description": (
369 "Interpreter that runs the code. Defaults to 'python'. Use 'node' only "
370 "for docx-js creation (native footnotes, TOC, etc.) where python-docx "
371 "is too limited."
372 ),
373 "default": "python",
374 },
375 "input_files": {
376 "type": "array",
377 "items": {"type": "string"},
378 "description": (
379 "Workspace document paths to mount in the sandbox at /sandbox/input/. "
380 "REQUIRED when modifying a document from a previous run — each sandbox is ephemeral. "
381 "DOCX blobs are extracted as files. "
382 "Example: ['contract.docx', 'Playbook/nda.md']"
383 )
384 },
385 "description": {
386 "type": "string",
387 "description": "Brief description of what this code does (shown to user)"
388 }
389 },
390 "required": ["code"]
391 }
392}
393
394CLONE_DOCUMENT_TOOL = {
395 "name": "clone_document",
396 "description": (
397 "Create the next version of a document before editing. Law firm versioning: "
398 "original → v2 → v3 → v4. All versions preserved. "
399 "Always pass the ORIGINAL document path — the backend automatically finds the latest "
400 "version and clones FROM it to the next number. "
401 "Call once at the start of each editing session, then edit_document on the new version."
402 ),
403 "input_schema": {
404 "type": "object",
405 "properties": {
406 "source_path": {
407 "type": "string",
408 "description": "Path of the document to clone (UUID or workspace path)."
409 },
410 "target_path": {
411 "type": "string",
412 "description": "Path for the clone. Omit to auto-generate versioned name (e.g., Contract_v2.docx)."
413 }
414 },
415 "required": ["source_path"]
416 }
417}
418
419EDIT_DOCUMENT_TOOL = {
420 "name": "edit_document",
421 "description": (
422 "Edit body text in an existing DOCX. This is the edit tool — "
423 "use it for changing clause text, replacing defined terms, "
424 "fixing typos, updating dates/amounts, deleting sections via "
425 "start_text/end_text range delete, filling template "
426 "placeholders. The server generates valid <w:ins>/<w:del> "
427 "tracked-change markup automatically and preserves run "
428 "properties (font, bold, size). Supports **bold** markdown "
429 "in new_text.\n\n"
430 "Call read_document(view='text') first to get the exact "
431 "current text to pass as old_text. Auto-clones to _v2.docx "
432 "on first edit so the original stays pristine."
433 ),
434 "input_schema": {
435 "type": "object",
436 "properties": {
437 "path": {
438 "type": "string",
439 "description": "Document path. Optional — if omitted, uses the active document (set by clone_document)."
440 },
441 "old_text": {
442 "type": "string",
443 "description": (
444 "Exact text to find and replace. Copy from read_document output. "
445 "Must be unique in the document — include enough surrounding context. "
446 "Use near_text if the same phrase appears multiple times."
447 )
448 },
449 "new_text": {
450 "type": "string",
451 "description": (
452 "Replacement text. Use **bold** for bold formatting. "
453 "Empty string to delete old_text."
454 )
455 },
456 "explanation": {
457 "type": "string",
458 "description": "Brief explanation of why this change is being made."
459 },
460 "start_text": {
461 "type": "string",
462 "description": "Range deletion: text at the START of the range to delete (inclusive). Use with end_text."
463 },
464 "end_text": {
465 "type": "string",
466 "description": "Range deletion: text at the END of the range to delete (inclusive). Use with start_text."
467 },
468 "near_text": {
469 "type": "string",
470 "description": "Disambiguation: text near the target when old_text appears multiple times."
471 }
472 },
473 "required": []
474 }
475}
476
477REVERT_EDIT_TOOL = {
478 "name": "revert_edit",
479 "description": (
480 "Undo specific tracked changes by revision ID. Surgically removes the tracked change "
481 "markup for the given IDs, restoring the original text. Other tracked changes are untouched. "
482 "Get revision IDs from ``get_revision_stats``."
483 ),
484 "input_schema": {
485 "type": "object",
486 "properties": {
487 "path": {
488 "type": "string",
489 "description": "Document path (UUID or workspace file path)."
490 },
491 "revision_ids": {
492 "type": "array",
493 "items": {"type": "integer"},
494 "description": "Revision IDs to revert (from ``get_revision_stats``)."
495 }
496 },
497 "required": ["path", "revision_ids"]
498 }
499}
500
501GET_REVISION_STATS_TOOL = {
502 "name": "get_revision_stats",
503 "description": (
504 "Get statistics about tracked changes in a DOCX document: insertion count, deletion count, "
505 "authors, total changes, and revision IDs. Use to check the current state of tracked changes.\n\n"
506 "Pass with_snippets=True to also get a per-revision detail list (id, type, author, date, "
507 "text_snippet, context_around) — needed for picking specific IDs for "
508 "accept_changes / reject_changes."
509 ),
510 "input_schema": {
511 "type": "object",
512 "properties": {
513 "path": {
514 "type": "string",
515 "description": "Document path (UUID or workspace file path)."
516 },
517 "with_snippets": {
518 "type": "boolean",
519 "description": (
520 "When true, return per-revision detail with text snippets and context. "
521 "Default false (counts and IDs only)."
522 ),
523 "default": False,
524 },
525 },
526 "required": ["path"]
527 }
528}
529
530ACCEPT_CHANGES_TOOL = {
531 "name": "accept_changes",
532 "description": (
533 "Accept SPECIFIC tracked changes by revision ID. For each ID: <w:ins> becomes permanent "
534 "(insertion stays); <w:del> is dropped (deletion stays gone). For batch finalization of "
535 "ALL changes, use accept_all_changes instead.\n\n"
536 "Workflow: call get_revision_stats(with_snippets=True) to see each change's text and "
537 "author, then pass the IDs you want to accept. Auto-clones the original to _v2 on first "
538 "call (mirrors edit_document safety net). Returns accepted_ids and not_found_ids so you "
539 "know which IDs landed."
540 ),
541 "input_schema": {
542 "type": "object",
543 "properties": {
544 "path": {
545 "type": "string",
546 "description": "Document path (UUID or workspace file path)."
547 },
548 "revision_ids": {
549 "type": "array",
550 "items": {"type": "integer"},
551 "description": "List of revision IDs to accept (from get_revision_stats)."
552 },
553 },
554 "required": ["path", "revision_ids"]
555 }
556}
557
558PRODUCE_REDLINE_TOOL = {
559 "name": "produce_redline",
560 "description": (
561 "Produce a redlined comparison DOCX from two documents. The output is a Word-openable "
562 "DOCX showing path_b's differences from path_a as tracked changes — for the user to "
563 "review and accept/reject in Word. Routes through LibreOffice's native CompareDocuments "
564 "UNO dispatcher for proper paragraph-mark / run-property / table-cell handling.\n\n"
565 "When to call: user asks 'show me what changed', 'redline this against the previous "
566 "version', 'produce a comparison document', etc. Distinct from `compare` (which returns "
567 "a text-level diff summary for the agent to reason about, no DOCX output)."
568 ),
569 "input_schema": {
570 "type": "object",
571 "properties": {
572 "path_a": {
573 "type": "string",
574 "description": "Baseline document path (before).",
575 },
576 "path_b": {
577 "type": "string",
578 "description": "Revised document path (after).",
579 },
580 "output_path": {
581 "type": "string",
582 "description": (
583 "Path for the redlined output DOCX. Name by content "
584 "(e.g. 'Acme Contract — Redline 2026-04-25.docx')."
585 ),
586 },
587 "author": {
588 "type": "string",
589 "description": "Author name to attach to the tracked changes. Default 'Anylegal.ai'.",
590 "default": "Anylegal.ai",
591 },
592 },
593 "required": ["path_a", "path_b", "output_path"],
594 },
595}
596
597INSTANTIATE_TEMPLATE_TOOL = {
598 "name": "instantiate_template",
599 "description": (
600 "Create a new DOCX from a template by filling placeholders. NO tracked changes "
601 "in the output — the result is a clean final document. The template is untouched.\n\n"
602 "When to use: user asks to 'create a board resolution from this template', 'fill in "
603 "this NDA template', 'instantiate this template with these values', etc. Output is "
604 "saved at the given output_path with the model-chosen name (e.g. "
605 "'Acme Board Resolution 2026-04-25.docx', not 'Template_v2.docx').\n\n"
606 "Formatting preservation: each placeholder's enclosing run properties (<w:rPr> — bold, "
607 "font, size) are preserved. Multi-run placeholders (e.g. '[●]' split across runs) are "
608 "handled correctly.\n\n"
609 "Distinct from edit_document: edit_document emits tracked changes for redline review. "
610 "Use instantiate_template ONLY for template fills where the user wants a final document, "
611 "not a marked-up draft. For per-placeholder review, use clone_document + edit_document "
612 "(then accept_all_changes when done)."
613 ),
614 "input_schema": {
615 "type": "object",
616 "properties": {
617 "template_path": {
618 "type": "string",
619 "description": "Path to the source template (e.g., 'Templates/Board_Resolution.docx').",
620 },
621 "output_path": {
622 "type": "string",
623 "description": (
624 "Path for the new document — name by content, not '_v2'. "
625 "Example: 'Acme Board Resolution 2026-04-25.docx'."
626 ),
627 },
628 "replacements": {
629 "type": "object",
630 "description": (
631 "Map of placeholder text → replacement text. Example: "
632 "{'[Disclosing Party]': 'Acme Corporation', '[Date]': '25 April 2026'}. "
633 "Each placeholder must appear in the template; missing ones are reported "
634 "in not_found.\n\n"
635 "Disambiguation: when the same token appears multiple times in the "
636 "template (e.g. '[●]' showing up at multiple placeholders), each key "
637 "must be a unique-in-document string. Include surrounding context — "
638 "e.g. 'Discount Rate is [●] %' instead of just '[●]'. Identical short "
639 "keys would only fill the first occurrence."
640 ),
641 "additionalProperties": {"type": "string"},
642 },
643 },
644 "required": ["template_path", "output_path", "replacements"],
645 },
646}
647
648REJECT_CHANGES_TOOL = {
649 "name": "reject_changes",
650 "description": (
651 "Reject SPECIFIC tracked changes by revision ID. For each ID: <w:ins> is dropped "
652 "(insertion disappears); <w:del> is unwrapped, restoring the deleted text. For batch "
653 "rejection of ALL changes, use reject_all_changes instead.\n\n"
654 "Workflow: call get_revision_stats(with_snippets=True) to see each change, then pass the "
655 "IDs you want to reject. Auto-clones the original to _v2 on first call. Returns "
656 "rejected_ids and not_found_ids."
657 ),
658 "input_schema": {
659 "type": "object",
660 "properties": {
661 "path": {
662 "type": "string",
663 "description": "Document path (UUID or workspace file path)."
664 },
665 "revision_ids": {
666 "type": "array",
667 "items": {"type": "integer"},
668 "description": "List of revision IDs to reject (from get_revision_stats)."
669 },
670 },
671 "required": ["path", "revision_ids"]
672 }
673}
674
675ACCEPT_ALL_CHANGES_TOOL = {
676 "name": "accept_all_changes",
677 "description": (
678 "Accept all tracked changes in a DOCX — produces a clean document with every "
679 "<w:ins> committed and every <w:del> removed. Routes through LibreOffice so every "
680 "OOXML edge case (nested changes, complex formatting, paragraph marks, table cells, "
681 "content controls, comment anchors) is handled correctly.\n\n"
682 "When to call: user asks to 'finalize', 'accept all changes', 'clean up', 'apply all "
683 "edits', or the review/redlining workflow is complete and the user wants the final "
684 "version. Pass output_path to save as a new document; omit to update in place."
685 ),
686 "input_schema": {
687 "type": "object",
688 "properties": {
689 "path": {
690 "type": "string",
691 "description": "Document path (UUID or workspace file path) of the DOCX with tracked changes."
692 },
693 "output_path": {
694 "type": "string",
695 "description": (
696 "Optional workspace path to save the cleaned DOCX as a new document "
697 "(e.g. 'Contract_Clean.docx'). If omitted, the source document is updated in place."
698 )
699 }
700 },
701 "required": ["path"]
702 }
703}
704
705ADD_COMMENT_TOOL = {
706 "name": "add_comment",
707 "description": (
708 "Add a margin comment to a DOCX document. Handles the four-file "
709 "coordination Word requires (comments.xml, commentsExtended.xml, "
710 "commentsIds.xml, commentsExtensible.xml) plus the relationship + "
711 "content-type registrations, so one tool call produces a fully-valid "
712 "commented document.\n\n"
713 "When to call: user asks you to 'comment on', 'flag', 'annotate', or "
714 "'leave a note about' specific text in a document. For replies to "
715 "existing comments, pass parent_id."
716 ),
717 "input_schema": {
718 "type": "object",
719 "properties": {
720 "path": {
721 "type": "string",
722 "description": "Document path (UUID or workspace file path).",
723 },
724 "target_text": {
725 "type": "string",
726 "description": (
727 "Text in the document to anchor the comment to. The "
728 "balloon appears next to this text in Word."
729 ),
730 },
731 "comment_text": {
732 "type": "string",
733 "description": "The comment body — what shows in the margin.",
734 },
735 "author": {
736 "type": "string",
737 "description": (
738 "Comment author. Default 'Anylegal.ai'. Use the user's "
739 "name from anylegal.md if available."
740 ),
741 "default": "Anylegal.ai",
742 },
743 "initials": {
744 "type": "string",
745 "description": "2-3 character author initials. Default 'A'.",
746 "default": "A",
747 },
748 "parent_id": {
749 "type": "integer",
750 "description": (
751 "Parent comment id for threaded replies. Omit for a new "
752 "top-level comment."
753 ),
754 },
755 "near_text": {
756 "type": "string",
757 "description": "Disambiguator when target_text matches multiple locations.",
758 },
759 },
760 "required": ["path", "target_text", "comment_text"],
761 },
762}
763
764REJECT_ALL_CHANGES_TOOL = {
765 "name": "reject_all_changes",
766 "description": (
767 "Reject all tracked changes in a DOCX — restores the document to its state before "
768 "any tracked edits were made. Routes through LibreOffice (same reliability rationale "
769 "as accept_all_changes).\n\n"
770 "When to call: user asks to 'reject all', 'revert all changes', 'undo my edits', or "
771 "to get back to the original version of a document that's been edited with tracked "
772 "changes. For reverting SPECIFIC edits by revision ID, use revert_edit instead."
773 ),
774 "input_schema": {
775 "type": "object",
776 "properties": {
777 "path": {
778 "type": "string",
779 "description": "Document path (UUID or workspace file path) of the DOCX with tracked changes."
780 },
781 "output_path": {
782 "type": "string",
783 "description": (
784 "Optional workspace path to save the restored DOCX as a new document. "
785 "If omitted, the source document is updated in place."
786 )
787 }
788 },
789 "required": ["path"]
790 }
791}
792
793from .todo_tool import TODO_WRITE_TOOL
794from .mode_tools import ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL
795
796SKILL_TOOL: Dict[str, Any] = {
797 "name": "Skill",
798 "description": (
799 "Invoke a skill by name. Skills contain required procedures for document "
800 "tasks (drafting, review, comparison, QA, research). The skill body is "
801 "returned as the tool result; follow it on the next turn.\n\n"
802 "**When to call:**\n"
803 "- User references a slash command like `/draft`, `/review`, `/qa`, "
804 "`/research`, `/compare`, `/setup`.\n"
805 "- User's task matches a skill even without the slash prefix (e.g. "
806 "\"draft an NDA\" → `Skill(skill=\"draft\")`).\n"
807 "- You need the procedure before starting any document task; the "
808 "skills contain required steps that the system prompt deliberately "
809 "does NOT duplicate.\n\n"
810 "**Rules:**\n"
811 "- **BLOCKING REQUIREMENT:** call this tool BEFORE generating any "
812 "other response about a document task. Do NOT describe what a skill "
813 "does without actually invoking it.\n"
814 "- Skills scope the tool pool — once invoked, only the tools the "
815 "skill declares (plus a small always-on set) are available to you "
816 "until the next user turn.\n"
817 "- Only invoke a skill listed in the 'Available Skills' section of "
818 "the system prompt. Never guess a skill name."
819 ),
820 "input_schema": {
821 "type": "object",
822 "properties": {
823 "skill": {
824 "type": "string",
825 "description": (
826 "Skill name, e.g. 'draft', 'review', 'qa', 'research', "
827 "'compare', 'docx-editing', 'setup'. Must match a skill "
828 "listed in Available Skills."
829 ),
830 },
831 "args": {
832 "type": "string",
833 "description": (
834 "Optional arguments passed with the slash command. "
835 "Prepended to the skill body as a context header."
836 ),
837 },
838 },
839 "required": ["skill"],
840 },
841}
842
843SEARCH_WORKSPACE_TOOL: Dict[str, Any] = {
844 "name": "search_workspace",
845 "description": (
846 "BM25 full-text search across the user's workspace documents AND the "
847 "compiled wiki pages. Use this FIRST for cross-document questions "
848 "(\"what indemnification caps have I agreed?\", \"which contracts mention "
849 "termination for convenience?\") — it's free (no LLM cost) and avoids "
850 "loading every doc.\n\n"
851 "Returns up to `top_k` results sorted by relevance, each with a snippet "
852 "showing the matched terms in context. After locating relevant docs, "
853 "use `read_document` for full text or `read_wiki_page` for the compiled "
854 "summary.\n\n"
855 "If the wiki has not been compiled yet, results will only include source "
856 "documents — still useful, just narrower."
857 ),
858 "input_schema": {
859 "type": "object",
860 "properties": {
861 "query": {
862 "type": "string",
863 "description": "Search query — keywords or phrases. Legal terms work well (e.g. 'liability cap', 'force majeure', 'data processing').",
864 },
865 "top_k": {
866 "type": "integer",
867 "description": "Maximum results to return (1-50). Default 10.",
868 "default": 10,
869 "minimum": 1,
870 "maximum": 50,
871 },
872 },
873 "required": ["query"],
874 },
875}
876
877READ_WIKI_PAGE_TOOL: Dict[str, Any] = {
878 "name": "read_wiki_page",
879 "description": (
880 "Read a single compiled wiki page by slug. The page is an LLM-generated "
881 "summary of one source document, with extracted parties, jurisdiction, "
882 "key clauses, and [[backlinks]] to related documents — typically much "
883 "shorter than the source.\n\n"
884 "Use after `search_workspace` returns a `wiki_page` hit, or after "
885 "`list_wiki_pages` shows a slug you want to load. For full source text, "
886 "use `read_document` on the page's `source` frontmatter field instead."
887 ),
888 "input_schema": {
889 "type": "object",
890 "properties": {
891 "slug": {
892 "type": "string",
893 "description": "Page slug, e.g. 'contracts/acme-msa', 'topics/data-residency'.",
894 },
895 },
896 "required": ["slug"],
897 },
898}
899
900LIST_WIKI_PAGES_TOOL: Dict[str, Any] = {
901 "name": "list_wiki_pages",
902 "description": (
903 "List compiled wiki pages, optionally filtered by category. Returns "
904 "pages grouped by category with title, parties, and jurisdiction "
905 "metadata. Use to browse what the wiki knows about, without loading "
906 "every page.\n\n"
907 "Categories: 'contracts', 'statutes', 'cases', 'memos', 'topics'. "
908 "Pass 'indexes' to get the list of cross-cutting indexes "
909 "(clause_library, by_party, by_jurisdiction, by_type, precedent_map) "
910 "available for browsing via the workspace UI."
911 ),
912 "input_schema": {
913 "type": "object",
914 "properties": {
915 "category": {
916 "type": "string",
917 "description": "Optional category filter. Omit to get all categories.",
918 "enum": ["contracts", "statutes", "cases", "memos", "topics", "indexes"],
919 },
920 },
921 "required": [],
922 },
923}
924
925APPEND_WIKI_NOTE_TOOL: Dict[str, Any] = {
926 "name": "append_wiki_note",
927 "description": (
928 "Append a timestamped note to Anylegal.ai's memory of this matter. "
929 "The note shows up in the Memory tab and is read by future agent "
930 "turns about this workspace. Cheap, no LLM cost.\n\n"
931 "**Two routing modes by `slug`:**\n"
932 "- **Provide `slug`** (e.g. `'contracts/acme-msa'`) when the note is "
933 "about that specific document. Use after `edit_document` adds a new "
934 "clause, after `accept_changes` ratifies a redline, or after research "
935 "uncovers something doc-specific (e.g. counterparty's standard "
936 "position on a clause).\n"
937 "- **Omit `slug`** when the note is a cross-cutting workspace insight "
938 "not tied to one doc — counterparty intel that crosses deals, user "
939 "preferences (e.g. \"prefers UK law for SaaS\"), strategic context, "
940 "stakeholder info. These land in the workspace-level journal and "
941 "inject into every future turn about this matter.\n\n"
942 "**Notes are descriptive facts only** — never severity tags, never "
943 "\"this is wrong\" judgments. \"User asked about cap structure today\" "
944 "yes; \"this clause is too restrictive\" no (that's an evaluation; "
945 "keep it in chat). Notes are additive — they never overwrite the "
946 "compiled summary or earlier notes. Keep each note focused and short "
947 "(1-3 sentences).\n\n"
948 "**Tag every note with `type`** so future retrievals can filter:\n"
949 "- `\"user\"` — facts about the lawyer themselves: role, jurisdiction, "
950 "expertise. Example: *\"User is a corporate associate at a UAE firm, "
951 "8 years VC experience.\"*\n"
952 "- `\"feedback\"` — how Anylegal.ai should behave: format, tone, "
953 "process. Example: *\"User wants all liability caps flagged HIGH RISK.\"*\n"
954 "- `\"project\"` (default) — facts about the matter: parties, "
955 "decisions, timing, intel. Example: *\"Acme's Jane Doe is the "
956 "negotiator; she's lenient on caps but firm on IP.\"*\n"
957 "- `\"reference\"` — pointers to external resources. Example: "
958 "**Do NOT save** as notes (these are noise):\n"
959 "- Things derivable from doc content (the wiki has them).\n"
960 "- Audit-trail of edits (the doc's track-changes captures it).\n"
961 "- Things already in `anylegal.md` or `Playbook/*.md` (already injected).\n"
962 "- Ephemeral chat state (\"user said hi\", \"acknowledging request\").\n"
963 "- Evaluative claims (\"this is risky\") — keep in chat where the user can interrogate."
964 ),
965 "input_schema": {
966 "type": "object",
967 "properties": {
968 "slug": {
969 "type": "string",
970 "description": "Optional. Wiki page slug, e.g. 'contracts/acme-msa'. Omit for workspace-level notes (cross-cutting insights). Use list_wiki_pages to find slugs.",
971 },
972 "note": {
973 "type": "string",
974 "description": "The fact to record. 1-3 sentences, lawyer-readable. Descriptive only — what happened, what was learned, what was decided. Not evaluations.",
975 },
976 "type": {
977 "type": "string",
978 "enum": ["user", "feedback", "project", "reference"],
979 "description": "Taxonomy tag for retrieval. user=facts about the lawyer; feedback=how AI should behave; project=facts about the matter (DEFAULT); reference=external resource pointers.",
980 },
981 },
982 "required": ["note"],
983 },
984}
985
986UPDATE_WIKI_PAGE_TOOL: Dict[str, Any] = {
987 "name": "update_wiki_page",
988 "description": (
989 "Replace a wiki page's compiled summary body. Use SPARINGLY — only "
990 "when the existing summary is actively misleading after a major doc "
991 "restructure. For incremental changes, use `append_wiki_note` "
992 "instead (additive, lower-risk).\n\n"
993 "Your replacement should be markdown: 1-2 paragraphs of summary, "
994 "followed by a bullet list of key clauses and values. Do NOT "
995 "duplicate the frontmatter (parties, jurisdiction) in the body — "
996 "those are stored separately."
997 ),
998 "input_schema": {
999 "type": "object",
1000 "properties": {
1001 "slug": {"type": "string", "description": "Wiki page slug."},
1002 "content": {
1003 "type": "string",
1004 "description": "New markdown summary body. Replaces the existing compiled_body.",
1005 },
1006 },
1007 "required": ["slug", "content"],
1008 },
1009}
1010
1011SET_WIKI_METADATA_TOOL: Dict[str, Any] = {
1012 "name": "set_wiki_metadata",
1013 "description": (
1014 "Patch a single frontmatter field on a wiki page. Use when you've "
1015 "established a fact that the compile didn't extract correctly — "
1016 "e.g. the counterparty was misidentified, the governing law is "
1017 "different from what the doc literally says, or a subject area was "
1018 "missed.\n\n"
1019 "Allowed keys: parties (list), jurisdiction (string), "
1020 "effective_date (ISO date string), subject_areas (list), title "
1021 "(string)."
1022 ),
1023 "input_schema": {
1024 "type": "object",
1025 "properties": {
1026 "slug": {"type": "string", "description": "Wiki page slug."},
1027 "key": {
1028 "type": "string",
1029 "enum": ["parties", "jurisdiction", "effective_date", "subject_areas", "title"],
1030 },
1031 "value": {
1032 "description": "New value for the field. Type depends on key (list for parties/subject_areas, string for others).",
1033 },
1034 },
1035 "required": ["slug", "key", "value"],
1036 },
1037}
1038
1039DELETE_WIKI_PAGE_TOOL: Dict[str, Any] = {
1040 "name": "delete_wiki_page",
1041 "description": (
1042 "Remove a wiki page. Use ONLY when the source document has been "
1043 "deleted from the workspace; for everything else (rename, move, "
1044 "update content), prefer the other edit tools."
1045 ),
1046 "input_schema": {
1047 "type": "object",
1048 "properties": {
1049 "slug": {"type": "string", "description": "Wiki page slug to remove."},
1050 },
1051 "required": ["slug"],
1052 },
1053}
1054
1055SUGGEST_INSTRUCTION_TOOL: Dict[str, Any] = {
1056 "name": "suggest_instruction",
1057 "description": (
1058 "Propose an addition to the user's `anylegal.md` instructions. This "
1059 "tool DOES NOT write the file — it surfaces a chat card with the "
1060 "proposed text and an 'Add to instructions' button. The user clicks "
1061 "the button if they accept; the AI never auto-writes anylegal.md.\n\n"
1062 "**Use when** chat surfaces a durable preference the user has stated "
1063 "(or strongly implied) that should govern future sessions:\n"
1064 "- *\"I always want UK-law fallback for cross-border SaaS deals.\"*\n"
1065 "- *\"For Acme matters, default the liability cap to 12 months of fees.\"*\n"
1066 "- *\"Use UK English spelling in every doc.\"*\n\n"
1067 "**Do NOT use** for:\n"
1068 "- Transient observations (those go in chat as prose).\n"
1069 "- Per-doc facts (use `append_wiki_note` with a slug instead).\n"
1070 "- Cross-cutting workspace notes (use `append_wiki_note` without a slug — that's the AI's journal).\n\n"
1071 "Provide a `rationale` (one sentence) explaining why the user benefits "
1072 "— the chat card surfaces it above the proposed text so the user can "
1073 "decide quickly whether to accept."
1074 ),
1075 "input_schema": {
1076 "type": "object",
1077 "properties": {
1078 "text": {
1079 "type": "string",
1080 "description": "The instruction line to propose. One sentence, imperative voice, written for the AI to follow.",
1081 },
1082 "rationale": {
1083 "type": "string",
1084 "description": "1-sentence justification shown to the user above the button. Lawyer-readable.",
1085 },
1086 "target_path": {
1087 "type": "string",
1088 "description": "Optional. Folder-scoped target (e.g. 'Clients/Acme/anylegal.md'). Defaults to root anylegal.md.",
1089 },
1090 },
1091 "required": ["text", "rationale"],
1092 },
1093}
1094
1095WORKSPACE_TOOLS: List[Dict[str, Any]] = [
1096
1097 LIST_DOCUMENTS_TOOL,
1098 READ_DOCUMENT_TOOL,
1099 CREATE_DOCUMENT_TOOL,
1100 CREATE_FOLDER_TOOL,
1101 DELETE_DOCUMENT_TOOL,
1102 DELETE_FOLDER_TOOL,
1103
1104 WEB_SEARCH_TOOL,
1105 WEB_FETCH_TOOL,
1106
1107 RUN_CODE_TOOL,
1108
1109 CLONE_DOCUMENT_TOOL,
1110 EDIT_DOCUMENT_TOOL,
1111 ADD_COMMENT_TOOL,
1112 REVERT_EDIT_TOOL,
1113 GET_REVISION_STATS_TOOL,
1114 ACCEPT_ALL_CHANGES_TOOL,
1115 REJECT_ALL_CHANGES_TOOL,
1116 ACCEPT_CHANGES_TOOL,
1117 REJECT_CHANGES_TOOL,
1118 INSTANTIATE_TEMPLATE_TOOL,
1119 PRODUCE_REDLINE_TOOL,
1120
1121 COMPARE_TOOL,
1122
1123 SEARCH_WORKSPACE_TOOL,
1124 READ_WIKI_PAGE_TOOL,
1125 LIST_WIKI_PAGES_TOOL,
1126 APPEND_WIKI_NOTE_TOOL,
1127 UPDATE_WIKI_PAGE_TOOL,
1128 SET_WIKI_METADATA_TOOL,
1129 DELETE_WIKI_PAGE_TOOL,
1130 SUGGEST_INSTRUCTION_TOOL,
1131
1132 TODO_WRITE_TOOL,
1133
1134 *([ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL] if _PLANNER_ENABLED else []),
1135
1136 SKILL_TOOL,
1137]
1138
1139ALWAYS_ON_TOOLS = frozenset(
1140 {"Skill", "todo_write"}
1141 | ({"enter_plan_mode", "exit_plan_mode"} if _PLANNER_ENABLED else set())
1142)
1143
1144PLAN_MODE_TOOL_NAMES = {
1145 "list_documents",
1146 "read_document",
1147 "web_search",
1148 "web_fetch",
1149 "compare",
1150 "todo_write",
1151 "exit_plan_mode",
1152 "Skill",
1153}
1154
1155_TOOL_MAP: Dict[str, Dict[str, Any]] = {
1156 tool["name"]: tool
1157 for tool in WORKSPACE_TOOLS
1158}
1159
1160def get_workspace_tools() -> List[Dict[str, Any]]:
1161 """Return the model-facing tool pool."""
1162 return list(WORKSPACE_TOOLS)
1163
1164def get_tool_schema(name: str) -> Optional[Dict[str, Any]]:
1165 """Get the schema for a specific tool by name."""
1166 return _TOOL_MAP.get(name)
1167
1168def get_tools_for_skill(tool_names: List[str]) -> List[Dict[str, Any]]:
1169 """Get tool schemas for a list of tool names (used by skills)."""
1170 return [_TOOL_MAP[name] for name in tool_names if name in _TOOL_MAP]
1171
1172def get_all_tool_names() -> List[str]:
1173 """Get list of all available tool names."""
1174 return list(_TOOL_MAP.keys())
1175
1176TOOL_CATEGORIES = {
1177 "document_management": ["list_documents", "read_document", "create_document", "create_folder", "delete_document", "delete_folder"],
1178 "web_research": ["web_search", "web_fetch"],
1179 "code_execution": ["run_code"],
1180 "docx_editing": ["edit_document", "clone_document", "add_comment", "revert_edit", "get_revision_stats", "accept_all_changes", "reject_all_changes", "accept_changes", "reject_changes", "instantiate_template", "produce_redline"],
1181 "comparison": ["compare"],
1182}
1183