diff --git a/prompts/dm-system.md b/prompts/dm-system.md index 6383fd8..7fb10f1 100644 --- a/prompts/dm-system.md +++ b/prompts/dm-system.md @@ -22,6 +22,7 @@ This is collaborative storytelling in a fantasy game. Players may explore morall | `tune` | Update your style/personality tuning based on player feedback | | `end_session` | Gracefully end the session | | `enter_initiative` | Enter initiative mode for combat or turn-based encounters | +| `run_code` | Run Python in a sandbox — all your tools available as functions | ### Initiative Mode (during initiative) | Tool | Purpose | diff --git a/pyproject.toml b/pyproject.toml index 19bbbc5..6e227ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastembed>=0.4", "httpx>=0.27", "mcp>=1.9", + "pydantic-monty>=0.0.9", "pymupdf>=1.24", "pymupdf4llm>=0.0.17", "pysqlite3-binary>=0.5", diff --git a/src/storied/engine.py b/src/storied/engine.py index 9e2540e..bfa5476 100644 --- a/src/storied/engine.py +++ b/src/storied/engine.py @@ -36,15 +36,20 @@ def load_prompt(name: str, prompts_path: Path | None = None) -> str: return path.read_text() -def _extract_roll_reason(tool_json: str) -> str | None: - """Extract the reason field from accumulated roll tool JSON.""" +def _extract_json_field(tool_json: str, field: str) -> str | None: + """Extract a named field from accumulated tool JSON.""" try: args = json.loads(tool_json) - return args.get("reason") + return args.get(field) except (json.JSONDecodeError, AttributeError): return None +def _extract_roll_reason(tool_json: str) -> str | None: + """Extract the reason field from accumulated roll tool JSON.""" + return _extract_json_field(tool_json, "reason") + + def _tool_notification(name: str) -> str: """Build a friendly tool notification string from an MCP tool name. @@ -71,6 +76,7 @@ def _tool_notification(name: str) -> str: "heal": "Healing", "condition": "Updating condition", "end_initiative": "Ending initiative", + "run_code": "Running code", } label = labels.get(short, short) return f"[{label}...]" @@ -432,7 +438,7 @@ class DMEngine: if short == "create_character": self.character_created = True - if short == "roll" and not self.debug: + if short in ("roll", "run_code") and not self.debug: deferred_notification = True elif self.debug: yield f"[→ {short}(...)]" @@ -449,6 +455,10 @@ class DMEngine: reason = _extract_roll_reason(current_tool_json) label = f"Rolling {reason}" if reason else "Rolling" yield f"[{label}...]" + elif deferred_notification and current_tool_name == "run_code": + desc = _extract_json_field(current_tool_json, "description") + label = desc if desc else "Running code" + yield f"[{label}...]" if self.debug and current_tool_json: truncated = current_tool_json[:200] diff --git a/src/storied/initiative.py b/src/storied/initiative.py index 4c5ff93..25e88c8 100644 --- a/src/storied/initiative.py +++ b/src/storied/initiative.py @@ -353,8 +353,16 @@ class InitiativeTracker: # --- Tool functions (thin wrappers around tracker methods) --- -def enter_initiative(combatants_raw: list[dict], tracker: InitiativeTracker) -> str: - """Enter initiative mode with pre-sorted combatants.""" + +def enter_initiative( + combatants_raw: list[dict], tracker: InitiativeTracker, +) -> str: + """Enter initiative mode for combat or any turn-based encounter. + + Provide all participants in their desired turn order (you handle + tie-breaking). Roll initiative for everyone first, then call this. + Initiative tools become available on the next turn. + """ if tracker.active: return "Initiative is already active. Call end_initiative first." @@ -372,6 +380,66 @@ def enter_initiative(combatants_raw: list[dict], tracker: InitiativeTracker) -> return tracker.begin(combatants) +def next_turn(tracker: InitiativeTracker) -> str: + """Advance to the next combatant's turn. Skips defeated. Call after resolving actions.""" + return tracker.next_turn() + + +def add_combatant( + tracker: InitiativeTracker, + name: str, + initiative: int, + hp: int, + hp_max: int, + ac: int, + is_player: bool = False, +) -> str: + """Add a combatant (reinforcements, surprised creatures waking up).""" + c = Combatant( + name=name, initiative=initiative, hp=hp, + hp_max=hp_max, ac=ac, is_player=is_player, + ) + return tracker.add_combatant(c) + + +def remove_combatant(tracker: InitiativeTracker, name: str) -> str: + """Remove a combatant who fled, was banished, or is otherwise out.""" + return tracker.remove_combatant(name) + + +def damage(tracker: InitiativeTracker, target: str, amount: int) -> str: + """Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.""" + return tracker.apply_damage(target, amount) + + +def heal(tracker: InitiativeTracker, target: str, amount: int) -> str: + """Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.""" + return tracker.apply_heal(target, amount) + + +def condition( + tracker: InitiativeTracker, + target: str, + condition_name: str, + action: str = "add", + duration: int = -1, + ends_on: str = "start", + source: str = "", +) -> str: + """Add or remove a condition. Duration counts down on the source's turn. Duration -1 = until manually removed.""" + if action == "remove": + return tracker.remove_condition(target, condition_name) + return tracker.add_condition( + target=target, condition=condition_name, + duration=duration, ends_on=ends_on, source=source, + ) + + +def end_initiative(tracker: InitiativeTracker) -> str: + """End initiative and return to narrative. Returns summary with rounds, defeated, and survivor HP.""" + return tracker.end() + + def execute_initiative_tool( tool_name: str, tool_input: dict, tracker: InitiativeTracker, ) -> str | None: @@ -386,38 +454,29 @@ def execute_initiative_tool( return "Initiative is not active. Call enter_initiative first." if tool_name == "next_turn": - return tracker.next_turn() + return next_turn(tracker) elif tool_name == "add_combatant": - c = Combatant( - name=tool_input["name"], - initiative=tool_input["initiative"], - hp=tool_input["hp"], - hp_max=tool_input["hp_max"], - ac=tool_input["ac"], - is_player=tool_input.get("is_player", False), + return add_combatant( + tracker, tool_input["name"], tool_input["initiative"], + tool_input["hp"], tool_input["hp_max"], tool_input["ac"], + tool_input.get("is_player", False), ) - return tracker.add_combatant(c) elif tool_name == "remove_combatant": - return tracker.remove_combatant(tool_input["name"]) + return remove_combatant(tracker, tool_input["name"]) elif tool_name == "damage": - return tracker.apply_damage(tool_input["target"], tool_input["amount"]) + return damage(tracker, tool_input["target"], tool_input["amount"]) elif tool_name == "heal": - return tracker.apply_heal(tool_input["target"], tool_input["amount"]) + return heal(tracker, tool_input["target"], tool_input["amount"]) elif tool_name == "condition": - action = tool_input.get("action", "add") - if action == "remove": - return tracker.remove_condition( - tool_input["target"], tool_input["condition"], - ) - return tracker.add_condition( - target=tool_input["target"], - condition=tool_input["condition"], + return condition( + tracker, tool_input["target"], tool_input["condition"], + action=tool_input.get("action", "add"), duration=tool_input.get("duration", -1), ends_on=tool_input.get("ends_on", "start"), source=tool_input.get("source", ""), ) elif tool_name == "end_initiative": - return tracker.end() + return end_initiative(tracker) return None @@ -447,12 +506,7 @@ _NO_INPUT: dict = {"type": "object", "properties": {}} ENTER_INITIATIVE_DEFINITION: dict = { "name": "enter_initiative", - "description": ( - "Enter initiative mode for combat or any turn-based encounter. " - "Provide all participants in their desired turn order (you handle " - "tie-breaking). Roll initiative for everyone first, then call this. " - "Initiative tools become available on the next turn." - ), + "description": enter_initiative.__doc__, "input_schema": { "type": "object", "properties": { @@ -472,29 +526,27 @@ ENTER_INITIATIVE_DEFINITION: dict = { COMBAT_TOOL_DEFINITIONS: list[dict] = [ {"name": "next_turn", - "description": "Advance to the next combatant's turn. Skips defeated. Call after resolving actions.", + "description": next_turn.__doc__, "input_schema": _NO_INPUT}, {"name": "add_combatant", - "description": "Add a combatant (reinforcements, surprised creatures waking up).", + "description": add_combatant.__doc__, "input_schema": { "type": "object", "properties": _COMBATANT_PROPS, "required": _COMBATANT_REQUIRED}}, {"name": "remove_combatant", - "description": "Remove a combatant who fled, was banished, or is otherwise out.", + "description": remove_combatant.__doc__, "input_schema": { "type": "object", "properties": {"name": {"type": "string", "description": "Combatant name"}}, "required": ["name"]}}, {"name": "damage", - "description": "Deal damage. Tracks defeat at 0 HP, reports Bloodied at half. Auto-syncs player character sheet.", + "description": damage.__doc__, "input_schema": _TARGET_AMOUNT_SCHEMA}, {"name": "heal", - "description": "Heal a combatant. Clamped to max HP. Revives defeated. Auto-syncs player character sheet.", + "description": heal.__doc__, "input_schema": _TARGET_AMOUNT_SCHEMA}, {"name": "condition", - "description": ( - "Add or remove a condition. For timed effects, duration counts down " - "on the source's turn. Duration -1 = until manually removed."), + "description": condition.__doc__, "input_schema": { "type": "object", "properties": { @@ -508,9 +560,7 @@ COMBAT_TOOL_DEFINITIONS: list[dict] = [ }, "required": ["target", "condition"]}}, {"name": "end_initiative", - "description": ( - "End initiative and return to narrative. Returns summary with rounds, " - "defeated, and survivor HP. Use the duration in your set_scene call."), + "description": end_initiative.__doc__, "input_schema": _NO_INPUT}, ] diff --git a/src/storied/mcp_server.py b/src/storied/mcp_server.py index c0cdc03..0dbe45e 100644 --- a/src/storied/mcp_server.py +++ b/src/storied/mcp_server.py @@ -24,6 +24,7 @@ from storied.initiative import ( ENTER_INITIATIVE_DEFINITION, INITIATIVE_KEEP_NARRATIVE, ) +from storied.sandbox import build_tool_signatures from storied.tools import ( EntityIndex, PLANNER_TOOL_DEFINITIONS, @@ -35,6 +36,8 @@ from storied.tools import ( seeder_execute_tool, ) +_tool_signatures: str | None = None + TOOL_SETS: dict[str, list[dict]] = { "planner": PLANNER_TOOL_DEFINITIONS, "seeder": SEEDER_TOOL_DEFINITIONS, @@ -57,9 +60,15 @@ def _dm_tool_definitions(ctx: ToolContext) -> list[dict]: def _to_mcp_tool(defn: dict) -> Tool: """Convert an Anthropic-format tool definition to an MCP Tool.""" + global _tool_signatures + desc = defn.get("description", "") + if "{tool_signatures}" in desc: + if _tool_signatures is None: + _tool_signatures = build_tool_signatures() + desc = desc.replace("{tool_signatures}", _tool_signatures) return Tool( name=defn["name"], - description=defn.get("description", ""), + description=desc, inputSchema=defn["input_schema"], ) diff --git a/src/storied/sandbox.py b/src/storied/sandbox.py new file mode 100644 index 0000000..13bb335 --- /dev/null +++ b/src/storied/sandbox.py @@ -0,0 +1,136 @@ +"""Sandboxed Python execution via Pydantic Monty. + +Provides an `execute` function that runs arbitrary Python code in a secure +Rust-based sandbox with no filesystem, network, or environment access. +All DM tools are available as host functions when a ToolContext is provided. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import pydantic_monty + +from storied.dice import roll as dice_roll + +if TYPE_CHECKING: + from storied.tools import ToolContext + +_LIMITS = pydantic_monty.ResourceLimits( + max_duration_secs=5.0, + max_memory=10 * 1_024 * 1_024, # 10 MB +) + +# Functions whose signatures we can read with inspect +_TOOL_FUNCTIONS: dict[str, str] = {} + + +def _roll_host(notation: str) -> dict[str, Any]: + """Host function: roll dice and return the result dict.""" + return dice_roll(notation).to_dict() + + +def _build_host_functions( + ctx: ToolContext | None, + extra: dict[str, Callable[..., Any]] | None, +) -> dict[str, Callable[..., Any]]: + """Build the full set of host functions for the sandbox.""" + fns: dict[str, Callable[..., Any]] = {"roll": _roll_host} + + if ctx is not None: + from storied.tools import TOOL_DEFINITIONS, execute_tool + from storied.initiative import ( + COMBAT_TOOL_DEFINITIONS, + ENTER_INITIATIVE_DEFINITION, + ) + + all_defs = TOOL_DEFINITIONS + COMBAT_TOOL_DEFINITIONS + [ENTER_INITIATIVE_DEFINITION] + for defn in all_defs: + name = defn["name"] + if name in ("roll", "run_code"): + continue + + def _make(tool_name: str) -> Callable[..., str]: + def fn(**kwargs: Any) -> str: + return execute_tool(tool_name, kwargs, ctx) + return fn + + fns[name] = _make(name) + + if extra: + fns.update(extra) + + return fns + + +def build_tool_signatures() -> str: + """Build human-readable function signatures for all DM tools.""" + from storied.tools import ( + roll, recall, establish, mark, note_discovery, + set_scene, update_character, create_character, tune, end_session, + ) + from storied.initiative import ( + enter_initiative, next_turn, add_combatant, remove_combatant, + damage, heal, condition, end_initiative, + ) + + all_fns = [ + roll, recall, establish, mark, note_discovery, set_scene, + update_character, create_character, tune, end_session, + enter_initiative, next_turn, add_combatant, remove_combatant, + damage, heal, condition, end_initiative, + ] + + lines: list[str] = [] + for fn in all_fns: + sig = inspect.signature(fn) + params = [p for p in sig.parameters.values() if p.name not in ("ctx", "tracker")] + param_str = ", ".join(str(p) for p in params) + lines.append(f"{fn.__name__}({param_str})") + + return "\n".join(lines) + + +def execute( + code: str, + ctx: ToolContext | None = None, + host_functions: dict[str, Callable[..., Any]] | None = None, +) -> str: + """Run Python code in a sandboxed environment and return the output. + + When ctx is provided, every DM tool is available as a host function: + recall(query="fireball", scope="rules") + establish(entity_type="npc", name="Bob", description="A baker") + roll("2d6+3") # returns dict with total, rolls, etc. + + Returns a string combining any printed output and the final expression + value. Errors are returned as formatted strings, never raised. + """ + if not code.strip(): + return "" + + fns = _build_host_functions(ctx, host_functions) + + output_lines: list[str] = [] + + def _capture(_stream: str, text: str) -> None: + output_lines.append(text) + + try: + m = pydantic_monty.Monty(code) + result = m.run( + external_functions=fns, + limits=_LIMITS, + print_callback=_capture, + ) + except (pydantic_monty.MontyRuntimeError, pydantic_monty.MontySyntaxError) as e: + return str(e) + + printed = "".join(output_lines).rstrip("\n") + if result is not None: + if printed: + return f"{printed}\n{result}" + return str(result) + return printed diff --git a/src/storied/tools.py b/src/storied/tools.py index 7c82a06..1133936 100644 --- a/src/storied/tools.py +++ b/src/storied/tools.py @@ -989,6 +989,34 @@ TOOL_DEFINITIONS = [ "required": ["situation"], }, }, + { + "name": "run_code", + "description": ( + "Run Python code in a secure sandbox. Use for calculations, random " + "generation, data formatting, or any computation the narrative needs. " + "All your tools are available as functions: recall(query=...), " + "establish(entity_type=..., name=...), roll('2d6+3'), etc. " + "No file/network access. Supported: variables, functions, loops, " + "conditionals, comprehensions, f-strings, plus re, json, datetime, " + "math, random from the standard library. No classes or imports beyond " + "builtins. Errors are returned as text — recover gracefully.\n\n" + "Available functions:\n{tool_signatures}" + ), + "input_schema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "What this code does in game terms (e.g., 'Designing cave system', 'Splitting treasure', 'Generating NPC schedule')", + }, + "code": { + "type": "string", + "description": "Python code to execute", + }, + }, + "required": ["description", "code"], + }, + }, ] @@ -1093,6 +1121,10 @@ def execute_tool(tool_name: str, tool_input: dict, ctx: ToolContext) -> str: threads=tool_input.get("threads"), ) + elif tool_name == "run_code": + from storied.sandbox import execute as sandbox_execute + return sandbox_execute(tool_input["code"], ctx=ctx) + else: return f"Unknown tool: {tool_name}" diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 0000000..5cb116e --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,149 @@ +"""Tests for sandboxed code execution via Monty.""" + +from storied.sandbox import build_tool_signatures, execute +from storied.tools import ToolContext + + +class TestBasicExecution: + """Core execution: expressions, statements, print output.""" + + def test_simple_expression(self): + result = execute("1 + 2") + + assert "3" in result + + def test_multi_line_script(self): + result = execute("x = 10\ny = 20\nx + y") + + assert "30" in result + + def test_print_captured(self): + result = execute('print("hello")\nprint("world")') + + assert "hello" in result + assert "world" in result + + def test_print_and_expression(self): + result = execute('print("debug")\n42') + + assert "debug" in result + assert "42" in result + + def test_no_return_value(self): + result = execute("x = 5") + + assert isinstance(result, str) + + def test_empty_code(self): + result = execute("") + + assert isinstance(result, str) + + +class TestHostFunctions: + """Host functions exposed to sandboxed code.""" + + def test_roll_available(self): + result = execute('r = roll("1d6")\nr["total"]') + + assert result # should contain a number 1-6 + + def test_roll_returns_dict(self): + result = execute("r = roll('2d6+3')\nprint('total=' + str(r['total']))") + + assert "total=" in result + + def test_custom_host_function(self): + result = execute( + "double(5)", + host_functions={"double": lambda x: x * 2}, + ) + + assert "10" in result + + +class TestErrorHandling: + """Errors returned as strings, not raised.""" + + def test_division_by_zero(self): + result = execute("1 / 0") + + assert "ZeroDivisionError" in result + + def test_name_error(self): + result = execute("undefined_variable") + + assert "NameError" in result + + def test_syntax_error(self): + result = execute("if True") + + assert result # Monty returns a parse error message + + def test_type_error(self): + result = execute('"hello" + 5') + + assert "TypeError" in result + + +class TestResourceLimits: + """Sandbox enforces time and memory limits.""" + + def test_infinite_loop_times_out(self): + result = execute("while True: pass") + + assert "time" in result.lower() or "timeout" in result.lower() + + +class TestSandboxSecurity: + """Dangerous operations are blocked.""" + + def test_no_file_access(self): + result = execute('open("/etc/passwd").read()') + + assert "Error" in result + + def test_no_os_access(self): + result = execute("import os\nos.environ") + + assert "NotImplementedError" in result + + +class TestToolBridge: + """DM tools exposed as direct host functions.""" + + def test_recall(self, ctx: ToolContext): + result = execute("recall(query='nonexistent')", ctx=ctx) + + assert "Nothing found" in result + + def test_establish(self, ctx: ToolContext): + result = execute( + "establish(entity_type='npc', name='Bob the Baker', " + "description='A friendly baker')", + ctx=ctx, + ) + + assert "Bob" in result + + def test_tools_not_available_without_ctx(self): + result = execute("recall(query='test')") + + assert "Error" in result + + +class TestToolSignatures: + """Dynamic signature builder for the run_code tool description.""" + + def test_includes_all_tools(self): + sigs = build_tool_signatures() + + for name in ["roll", "recall", "establish", "mark", "damage", "heal", + "enter_initiative", "end_initiative", "next_turn"]: + assert f"{name}(" in sigs + + def test_excludes_internal_params(self): + sigs = build_tool_signatures() + + assert "ctx:" not in sigs + assert "tracker:" not in sigs diff --git a/uv.lock b/uv.lock index f7d0a32..f22d86c 100644 --- a/uv.lock +++ b/uv.lock @@ -1105,6 +1105,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] +[[package]] +name = "pydantic-monty" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/11/d4024f543e15107c2e8081a294fb09d5ef57a32ca6fa12823d5887a3eaf1/pydantic_monty-0.0.9.tar.gz", hash = "sha256:9d3a85ce2e861b795b39d392410ac3620eb12261fdef6e1bc73edd6121d3c844", size = 888855, upload-time = "2026-03-28T13:18:27.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2b/4aaf85c79df3b47767d4c065a08b60c59f76b7611cbe29c7024e641a6dc1/pydantic_monty-0.0.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2c7b92ce83ba9f7d433f445da525243ce267b362a9fc8ecc15b322da91a595e2", size = 6918436, upload-time = "2026-03-28T13:18:58.99Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/a0a947eebb2e2a0db1775e374bc11dab549f46d6505d7d691368be984dcf/pydantic_monty-0.0.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b3d8e128f1822af6572bf6626ba9c2a2be53a8b291216809fb89e719272ea66", size = 6951931, upload-time = "2026-03-28T13:19:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e9/cf75febeafde9f6a3acddc6423bef3aed55525105bb1e2f32ee63e5c92a5/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a066a552ec3b358af5da11bf02ed8f3b2c0ae7b566a9bd38545176a6578d0bae", size = 6722630, upload-time = "2026-03-28T13:18:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/47b15bca41854ae41f0a83ea57a1aa0fad0805add7ada8b03f7f3c361e0f/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b51a3f809ae84dbd85bfcb307e98e759707a6c8097178806613c565fa6155ba4", size = 7010504, upload-time = "2026-03-28T13:18:17.094Z" }, + { url = "https://files.pythonhosted.org/packages/a1/eb/969453fe63080c9cb1461cf4e13821058bbffb7da83bda88bc02cd9948ee/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83b01931866e73caaaec308aadfbe2655006fc456e28c36845306d699359e11b", size = 7545150, upload-time = "2026-03-28T13:18:41.873Z" }, + { url = "https://files.pythonhosted.org/packages/45/86/802d7a6a47c97ea5bae93cff711a45a7be1223da279f21a79d3cba66b85b/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fa858e49bde281fa48e27961813740f0cc47e6ccf4f3a438e5e513c9e328978", size = 7750797, upload-time = "2026-03-28T13:18:51.355Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1e/e13202123771aaeb4ec0b86b85236923d57ea4fb661a6c8c01ebb84f7ce6/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8525c9f7baff77097786da6e9b51ad21f09242b5ff0d590f007c0d7f350fb3af", size = 7481146, upload-time = "2026-03-28T13:17:45.45Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/c205a267c799862faf5a186e7612a744af1e1deeaa9c6f40888d6b08e800/pydantic_monty-0.0.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59fcfd27dc7e65a9ac909f964c9ab5aec083cd6d3781ac0ff5afb06f481adf34", size = 7454819, upload-time = "2026-03-28T13:17:47.425Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a6/47066efd5c0e25cd6c4ea15ee91fd1291be2bd07744edaeab66c2d0e659f/pydantic_monty-0.0.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c30748cd5c0d9d2028d372372cd0bd9476b8508632372ec285d97f2ee2e9b40e", size = 6901304, upload-time = "2026-03-28T13:19:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/ee0edcd279d5ff6c74ec31db27777809d03d9960f32ba50e9b43765e173c/pydantic_monty-0.0.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:987e5b07ed56431bc94a586a7aff777c9928675bb938e8c15906dc8da49ac012", size = 7342554, upload-time = "2026-03-28T13:18:02.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/8f/a59673fc3d543916a7866e028d29ee2ee2c1493c4d804454be57ad2e081a/pydantic_monty-0.0.9-cp312-cp312-win32.whl", hash = "sha256:02d9d08eb0affc79491eebae0caed5559d5370cf6b806a9e1d67056bf3700c6d", size = 6816176, upload-time = "2026-03-28T13:17:41.153Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1c/ff4618ec34f67eade81ca2405d7da529ff3cb94a7c23034aa86a31bf3998/pydantic_monty-0.0.9-cp312-cp312-win_amd64.whl", hash = "sha256:7b705f76bb0b5c8307da564db06d550c24cd42fb6ece205e89ff8f2c24b9b8cf", size = 7571602, upload-time = "2026-03-28T13:17:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/89/f7/8b84b390527dad31c3a011a2242452a21e8199823d3bfc9190f4830c7837/pydantic_monty-0.0.9-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c3211eff69511cbf3a3b4422c59851bfb7121cd8757da74d2e10244795d2d178", size = 6917197, upload-time = "2026-03-28T13:19:10.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/60/815b882e31683fd8e0b8fc19d7aa772df92e5fff68d02b39b4b5b7bd83e7/pydantic_monty-0.0.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:586d68268ba74b76be7e88c5e7bfec6710466144b53a336bcc840752ed3efabc", size = 6951689, upload-time = "2026-03-28T13:17:30.929Z" }, + { url = "https://files.pythonhosted.org/packages/51/4b/2c7e4549251e300633da6afc8a1f40fc6e196d220e3b5773b9b13e5086f7/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9ecb857f814075f40cce780cdff4e661cd8f9e4139f5423d2e7db87be2f12ab", size = 6722305, upload-time = "2026-03-28T13:18:12.397Z" }, + { url = "https://files.pythonhosted.org/packages/27/c4/c3eb404eaf8f4f3cdb870795649c156ff4de8ed18c8a6ee28b4a9de26392/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c70b783b54891b33652852a8b97196550c1a2208a20ed527a11322c026c0b6", size = 7010210, upload-time = "2026-03-28T13:18:47.805Z" }, + { url = "https://files.pythonhosted.org/packages/a4/02/b3cb448bec463f5ade5415bd8360e9e1abe9ae5922fa7279f81fb71fede3/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:83ecdae13224b54e0fe1081249c7ba903603fc1f91e9104463365221dfedb6a7", size = 7543621, upload-time = "2026-03-28T13:18:21.69Z" }, + { url = "https://files.pythonhosted.org/packages/a8/22/d5bad5a2c26b3a9f3e410b19c580b558a29c86cc4432acb4699357e6a1f2/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d758730fc2c52916a787a3b98187166df57ae5410df83a1232b03f28a1648c68", size = 7750752, upload-time = "2026-03-28T13:18:37.48Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/b5fdb4d407351405172c7ec1ae3cca391178edbfc963ad7c2f72c60f6219/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30167ac5da02f8c787abf8fd5b9078d278f1e0d7d34228ab533f6895a6cfaeae", size = 7480766, upload-time = "2026-03-28T13:18:31.434Z" }, + { url = "https://files.pythonhosted.org/packages/df/50/ee98ba9a03024a69a4a959e0876e9ac41d87128e2ce38035eb071bd599d7/pydantic_monty-0.0.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a3f9be27c3d919273785128975f3ef77ff9f4acd0241f087670385ca1035661", size = 7453527, upload-time = "2026-03-28T13:18:24.052Z" }, + { url = "https://files.pythonhosted.org/packages/06/8f/1f724eb0e6bd617c4dc406d9602658546bab311d9cf8e6b1b3a18ab99cfb/pydantic_monty-0.0.9-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6c95adfa982a1f5e54661af16d85cb9a3eb063d1b5ad573d4725f1f7f3e58f99", size = 6900638, upload-time = "2026-03-28T13:18:54.918Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9e/ded78efb6646a772710413ff8531c57a2c53a5080c25e0d05cd7741157ed/pydantic_monty-0.0.9-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d682cce29303913991464f5c874e28ff3f9a10b1d6feb0bd0a8aa1d0344ee8d3", size = 7342105, upload-time = "2026-03-28T13:17:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d5/06070569726af1ff0f6ae4a4d48eadbcd3df2be3d52430f813176f56e249/pydantic_monty-0.0.9-cp313-cp313-win32.whl", hash = "sha256:cf409b859bb23fbe95051dbf5ffd093a3324cd610547783f6a47062ab2db11dc", size = 6815315, upload-time = "2026-03-28T13:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/e8/34/bfe47278242f84be0cd3687cb8a6165df22335ba3b3940596bf4681bfeba/pydantic_monty-0.0.9-cp313-cp313-win_amd64.whl", hash = "sha256:565ef2628b5fd840c178b6c6c223d74e9fd3c28dd83a405ae0b87f864fa37c94", size = 7571256, upload-time = "2026-03-28T13:17:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/32/fe/1ab7a8fd72456f5cad945260961aeeb217d910f0a44edc0f8fa79af26857/pydantic_monty-0.0.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:eef159f7a1496806d787e38abe271b8e39ea777e60f7bab06eaa60ee7224f619", size = 6919711, upload-time = "2026-03-28T13:19:21.869Z" }, + { url = "https://files.pythonhosted.org/packages/93/55/e9d476b643107a07a9bb540cc9313d0db614479b38817a0ba2df5d6c03ba/pydantic_monty-0.0.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1e91005ea5c532c03a35a4fc589c2a73f647edf38f3a02d8438782f184726e2", size = 6970765, upload-time = "2026-03-28T13:19:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/d2/24/b177895c9799b2a790eb055149774120c11144551dd6d97232242b4cb1d3/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d547c5ffc96dcb0574de83551dd4ed184df47e66f39600059230a17ac100185", size = 6723034, upload-time = "2026-03-28T13:18:33.47Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9b/ff8c9e948d8a28e9f03feb93813844b3095f943b63455f4f52aedf8c2582/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5611e32c3b4ca080b14b61be351c76f81e9e3cbc6c622663ffde964c06ee87c2", size = 7011075, upload-time = "2026-03-28T13:19:02.952Z" }, + { url = "https://files.pythonhosted.org/packages/b5/52/165067cfb23ba7c31eb6ec3aa335900f58b9e4a7d831ef7bf6e07f50804e/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b25cf37c2e680b4ed1e290cbd17f607f621caad340ffc05733101c735d2a868", size = 7541685, upload-time = "2026-03-28T13:19:19.918Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/ee47760be7a78634699ba1170a468d03dbcb51eaaae732eb579cc94382e1/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ccb49df19577e97c55dd9853ec1e57936c352ef9e34321c71fde61020a751525", size = 7751220, upload-time = "2026-03-28T13:17:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/93/9d/d5a6f0eba4cd3b626d2d71f93f1dbd4f7e7cf83e68dd88231ee92681d15b/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da129cfe11b084972b3d889e48db80084efd355a1f9ab0efc4fdcc0eda8d25ff", size = 7502146, upload-time = "2026-03-28T13:17:53.523Z" }, + { url = "https://files.pythonhosted.org/packages/16/79/7f257f08357bf6e4854f65900046a14c008eaabc31e604daa533d5efd7bf/pydantic_monty-0.0.9-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a6142fbe432e2c9b58383320f489fccca141d442f3746c93e2aca995a9302a6", size = 7455712, upload-time = "2026-03-28T13:18:43.891Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/09840855d51f9a22022932b0a2c82cab73810b07de44b122427e83edffe0/pydantic_monty-0.0.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:829cc76d7b77ee250b0a5d34ee29b18bf5374ad54102c4a229143bd054dd5c5c", size = 6902487, upload-time = "2026-03-28T13:18:49.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a4/c158c9040b7c19b0f1cab9c67260d9e1068bef5dc48e7f07f8335eb1e6ee/pydantic_monty-0.0.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:66f8cd4139a932cd5b4772927656dcfbf71b16633e08872030768970db12f3a2", size = 7343760, upload-time = "2026-03-28T13:18:25.865Z" }, + { url = "https://files.pythonhosted.org/packages/77/95/5f293008d3b52ed87e4614a215a71163f4a04aa942cd009906008ae397b9/pydantic_monty-0.0.9-cp314-cp314-win32.whl", hash = "sha256:c12223734c1a20ab0fdec1a5ac187ebe7000a7425addd0a0d348d765f03d3bed", size = 6816935, upload-time = "2026-03-28T13:17:24.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/11457bfa549750da0278f7c4f7dbc793bb6754aa3fab21a92fbfc3bdf8af/pydantic_monty-0.0.9-cp314-cp314-win_amd64.whl", hash = "sha256:a21c26fb13c776118000f3d6989dcc3b1d8171250ba79f2b5dc4e912b53be658", size = 7592268, upload-time = "2026-03-28T13:19:04.742Z" }, +] + [[package]] name = "pydantic-settings" version = "2.13.1" @@ -1494,6 +1538,7 @@ dependencies = [ { name = "fastembed" }, { name = "httpx" }, { name = "mcp" }, + { name = "pydantic-monty" }, { name = "pymupdf" }, { name = "pymupdf4llm" }, { name = "pysqlite3-binary" }, @@ -1517,6 +1562,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "mcp", specifier = ">=1.9" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" }, + { name = "pydantic-monty", specifier = ">=0.0.9" }, { name = "pymupdf", specifier = ">=1.24" }, { name = "pymupdf4llm", specifier = ">=0.0.17" }, { name = "pysqlite3-binary", specifier = ">=0.5" },